This is an automated email from the ASF dual-hosted git repository.
englefly pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 3b8142fe487 [refactor](bucket-hash-aggregate) refactor fe part:
implement BucketHashAggregate in physical plan translate phase (#65024)
3b8142fe487 is described below
commit 3b8142fe48740bcd2d54c3c9bb558fe54d5da3f7
Author: minghong <[email protected]>
AuthorDate: Mon Aug 17 15:00:11 2026 +0800
[refactor](bucket-hash-aggregate) refactor fe part: implement
BucketHashAggregate in physical plan translate phase (#65024)
### What problem does this PR solve?
Refactor the FE part of PR #61495: remove PhysicalBucketedHashAggregate
and instead fuse shuffle and aggregation at the translator stage.
The BE-side operators (BucketedAggSinkOperatorX /
BucketedAggSourceOperatorX) and the BucketedAggregationNode remain
unchanged.
Benefits
1. Minimally invasive: Eliminates ~290 lines of dedicated plan node code
and ~100 lines of visitor/cost/property methods across 12 files. Net
reduction of ~200+ lines in the FE codebase.
2. No new optimizer types: Removes PHYSICAL_BUCKETED_HASH_AGGREGATE from
the PlanType enum, PlanVisitor, and all auto-generated pattern
descriptors. The optimizer operates on standard PhysicalHashAggregate +
PhysicalDistribute patterns that it already understands.
3. Cleaner separation of concerns: The "what to compute" decision stays
in the optimizer (cost model discount makes the one-phase path preferred
on single-BE). The "how to execute" decision — fusing aggregation and
distribution into a single BE operator — lives in
the translator, exactly where physical plan → executable plan lowering
belongs.
4. Reuses existing infrastructure: No new property derivation rules
needed. The one-phase aggregate already requests HASH distribution by
group keys. The cost model already compares one-phase vs two-phase
paths. Only a translator-side pattern match and a cost
discount are added.
5. Lower maintenance burden: When new post-processors or visitor methods
are added to the Nereids framework, PhysicalBucketedHashAggregate no
longer needs to be updated in parallel with PhysicalHashAggregate. The
two previously duplicated code paths (translator, post-processors,
property derivers) are now unified.
Issue Number: close #xxx
Related PR: #61495
Problem Summary:
### Release note
None
### Check List (For Author)
- Test <!-- At least one of them must be included. -->
- [ ] Regression test
- [ ] Unit Test
- [ ] Manual test (add detailed scripts or steps below)
- [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
- [ ] Previous test can cover this change.
- [ ] No code files have been changed.
- [ ] Other reason <!-- Add your reason? -->
- Behavior changed:
- [ ] No.
- [ ] Yes. <!-- Explain the behavior change -->
- Does this need documentation?
- [ ] No.
- [ ] Yes. <!-- Add document PR link here. eg:
https://github.com/apache/doris-website/pull/1214 -->
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label <!-- Add branch pick label that this PR
should merge into -->
---
.../org/apache/doris/nereids/cost/CostModel.java | 33 +-
.../glue/translator/PhysicalPlanTranslator.java | 439 ++++++++++++++-------
.../glue/translator/PlanTranslatorContext.java | 24 ++
.../post/ProjectAggregateExpressionsForCse.java | 12 +-
.../processor/post/RuntimeFilterPruner.java | 9 +-
.../post/materialize/LazySlotPruning.java | 7 -
.../properties/ChildOutputPropertyDeriver.java | 37 +-
.../properties/ChildrenPropertiesRegulator.java | 56 +++
.../nereids/properties/RequestPropertyDeriver.java | 9 -
.../implementation/SplitAggWithoutDistinct.java | 326 ---------------
.../doris/nereids/stats/StatsCalculator.java | 7 -
.../apache/doris/nereids/trees/plans/PlanType.java | 1 -
.../physical/PhysicalBucketedHashAggregate.java | 293 --------------
.../nereids/trees/plans/visitor/PlanVisitor.java | 6 -
.../apache/doris/nereids/util/AggregateUtils.java | 45 +++
.../properties/RequestPropertyDeriverTest.java | 20 -
.../implementation/BucketedAggregateTest.java | 69 +++-
.../statistics/query/QueryStatsRecorderTest.java | 28 --
.../agg_strategy/bucketed_hash_agg.out | 40 ++
.../agg_strategy/bucketed_hash_agg.groovy | 162 ++++++++
20 files changed, 735 insertions(+), 888 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/cost/CostModel.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/cost/CostModel.java
index e21832b00bc..92aafb32006 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/cost/CostModel.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/cost/CostModel.java
@@ -38,13 +38,13 @@ import
org.apache.doris.nereids.trees.expressions.ComparisonPredicate;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.SlotReference;
import org.apache.doris.nereids.trees.expressions.literal.Literal;
+import org.apache.doris.nereids.trees.plans.AggMode;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.PlanNodeAndHash;
import org.apache.doris.nereids.trees.plans.algebra.OlapScan;
import org.apache.doris.nereids.trees.plans.logical.LogicalCTEProducer;
import org.apache.doris.nereids.trees.plans.logical.LogicalUnion;
import org.apache.doris.nereids.trees.plans.physical.PhysicalAssertNumRows;
-import
org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEConsumer;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEProducer;
import org.apache.doris.nereids.trees.plans.physical.PhysicalDistribute;
@@ -65,6 +65,7 @@ import
org.apache.doris.nereids.trees.plans.physical.PhysicalStorageLayerAggrega
import org.apache.doris.nereids.trees.plans.physical.PhysicalTopN;
import org.apache.doris.nereids.trees.plans.physical.PhysicalUnion;
import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
+import org.apache.doris.nereids.util.AggregateUtils;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.SessionVariable;
import org.apache.doris.statistics.ColumnStatistic;
@@ -89,6 +90,8 @@ class CostModel extends PlanVisitor<Cost, PlanContext> {
// The cost of using external tables should be somewhat higher than using
internal tables,
// so when encountering a scan of an external table, a coefficient should
be applied.
static final double EXTERNAL_TABLE_SCAN_FACTOR = 5;
+ static final double BUCKETED_AGG_COST_DISCOUNT = 0.5;
+
private static final Logger LOG = LogManager.getLogger(CostModel.class);
private final int beNumber;
private final int parallelInstance;
@@ -96,7 +99,7 @@ class CostModel extends PlanVisitor<Cost, PlanContext> {
public CostModel(ConnectContext connectContext) {
SessionVariable sessionVariable = connectContext.getSessionVariable();
- if (sessionVariable.getBeNumberForTest() != -1) {
+ if (sessionVariable.getBeNumberForTest() > 0) {
// shape test, fix the BE number and instance number
beNumber = sessionVariable.getBeNumberForTest();
parallelInstance = 8;
@@ -363,25 +366,21 @@ class CostModel extends PlanVisitor<Cost, PlanContext> {
boolean isPartitioned =
!aggregate.getGroupByExpressions().isEmpty()
|| aggregate.getPartitionExpressions().filter(expressions
-> !expressions.isEmpty()).isPresent();
int factor = isPartitioned ? beNumber : 1;
- // global
+ double rowCost = inputStatistics.getRowCount() / factor;
+ // Bucketed fusion discount: when the one-phase GLOBAL
INPUT_TO_RESULT
+ // aggregate is eligible for translator fusion (correctness +
data-volume
+ // gates are enforced by ChildrenPropertiesRegulator), apply a
discount
+ // to prefer this path over two-phase aggregation.
+ if (aggregate.getAggMode() == AggMode.INPUT_TO_RESULT
+ && AggregateUtils.isBucketedHashAggEnabled(
+ aggregate.getGroupByExpressions().size())) {
+ rowCost *= BUCKETED_AGG_COST_DISCOUNT;
+ }
return Cost.of(context.getSessionVariable(),
- exprCost / 100 + inputStatistics.getRowCount() / factor,
- inputStatistics.getRowCount() / factor, 0);
+ exprCost / 100 + rowCost, rowCost, 0);
}
}
- @Override
- public Cost visitPhysicalBucketedHashAggregate(
- PhysicalBucketedHashAggregate<? extends Plan> aggregate,
PlanContext context) {
- // Bucketed agg is similar to one-phase agg: all computation on a
single BE,
- // but avoids exchange overhead. Cost is comparable to one-phase agg.
- Statistics inputStatistics = context.getChildStatistics(0);
- double exprCost = expressionTreeCost(aggregate.getExpressions());
- return Cost.of(context.getSessionVariable(),
- exprCost / 100 + inputStatistics.getRowCount(),
- inputStatistics.getRowCount(), 0);
- }
-
@Override
public Cost visitPhysicalHashJoin(
PhysicalHashJoin<? extends Plan, ? extends Plan> physicalHashJoin,
PlanContext context) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
index f466f3c2884..ee2b277d1ca 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
@@ -92,6 +92,7 @@ import org.apache.doris.nereids.trees.expressions.WindowFrame;
import org.apache.doris.nereids.trees.expressions.functions.Udf;
import
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
import org.apache.doris.nereids.trees.expressions.functions.agg.AggregateParam;
+import org.apache.doris.nereids.trees.expressions.functions.agg.AggregatePhase;
import
org.apache.doris.nereids.trees.expressions.functions.scalar.GroupingScalarFunction;
import
org.apache.doris.nereids.trees.expressions.functions.scalar.UniqueFunction;
import org.apache.doris.nereids.trees.plans.AbstractPlan;
@@ -109,7 +110,6 @@ import
org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalSort;
import org.apache.doris.nereids.trees.plans.physical.PhysicalAssertNumRows;
import
org.apache.doris.nereids.trees.plans.physical.PhysicalBaseExternalTableSink;
import org.apache.doris.nereids.trees.plans.physical.PhysicalBlackholeSink;
-import
org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEAnchor;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEConsumer;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEProducer;
@@ -162,6 +162,7 @@ import org.apache.doris.nereids.types.DataType;
import org.apache.doris.nereids.types.JsonType;
import org.apache.doris.nereids.types.MapType;
import org.apache.doris.nereids.types.StructType;
+import org.apache.doris.nereids.util.AggregateUtils;
import org.apache.doris.nereids.util.ExpressionUtils;
import org.apache.doris.nereids.util.JoinUtils;
import org.apache.doris.nereids.util.RowStoreFetchChecker;
@@ -1168,6 +1169,12 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
PhysicalHashAggregate<? extends Plan> aggregate,
PlanTranslatorContext context) {
+ // Bucketed fusion path: fuse one-phase GLOBAL aggregate + distribute
+ // into BucketedAggregationNode when applicable (single-BE, no
exchange needed).
+ if (shouldUseBucketedFusion(aggregate, context)) {
+ return visitBucketedFusion(aggregate, context);
+ }
+
PlanFragment inputPlanFragment = aggregate.child(0).accept(this,
context);
List<List<Expr>> distributeExprLists =
getDistributeExprs(aggregate.child(0));
@@ -1178,50 +1185,15 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
List<SlotReference> groupSlots =
collectGroupBySlots(groupByExpressions, outputExpressions);
ArrayList<Expr> execGroupingExpressions =
translateGroupByExprs(groupByExpressions, context);
// 2. collect agg expressions and generate agg function to slot
reference map
- List<Slot> aggFunctionOutput = Lists.newArrayList();
- ArrayList<FunctionCallExpr> execAggregateFunctions =
Lists.newArrayListWithCapacity(outputExpressions.size());
- AtomicBoolean hasPartialInAggFunc = new AtomicBoolean(false);
- Set<AggregateExpression> processedAggregateExpressions =
Sets.newIdentityHashSet();
- for (NamedExpression o : outputExpressions) {
- if (o.containsType(AggregateExpression.class)) {
- aggFunctionOutput.add(o.toSlot());
-
- o.foreach(c -> {
- if (c instanceof SessionVarGuardExpr) {
- SessionVarGuardExpr guardExpr = (SessionVarGuardExpr)
c;
- if (guardExpr.child() instanceof AggregateExpression) {
- AggregateExpression aggregateExpression =
(AggregateExpression) guardExpr.child();
- if
(processedAggregateExpressions.add(aggregateExpression)) {
- execAggregateFunctions.add(
- (FunctionCallExpr)
ExpressionTranslator.translate(guardExpr, context)
- );
- hasPartialInAggFunc.set(
-
aggregateExpression.getAggregateParam().aggMode.productAggregateBuffer);
- }
- }
- // Continue through transparent guards unless this
guard directly wraps
- // the aggregate expression already processed above.
- return guardExpr.child() instanceof
AggregateExpression;
- }
- if (c instanceof AggregateExpression) {
- AggregateExpression aggregateExpression =
(AggregateExpression) c;
- if
(processedAggregateExpressions.add(aggregateExpression)) {
- execAggregateFunctions.add(
- (FunctionCallExpr)
ExpressionTranslator.translate(aggregateExpression, context)
- );
- hasPartialInAggFunc.set(
-
aggregateExpression.getAggregateParam().aggMode.productAggregateBuffer);
- }
- return true;
- }
- return false;
- });
- }
- }
+ boolean[] hasPartialInAggFunc = new boolean[1];
+ Pair<List<Slot>, ArrayList<FunctionCallExpr>> aggResult =
+ collectAggFunctions(outputExpressions, hasPartialInAggFunc,
context);
+ List<Slot> aggFunctionOutput = aggResult.first;
+ ArrayList<FunctionCallExpr> execAggregateFunctions = aggResult.second;
// An agg may have different functions, some product buffer, some
product result.
// The criterion for passing it to the be stage is: as long as there
is a product buffer function in agg,
// it must be isPartial
- boolean isPartial = hasPartialInAggFunc.get();
+ boolean isPartial = hasPartialInAggFunc[0];
// 3. generate output tuple
Pair<TupleDescriptor, List<Integer>> tupleAndIds =
@@ -1305,92 +1277,6 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
return inputPlanFragment;
}
- @Override
- public PlanFragment visitPhysicalBucketedHashAggregate(
- PhysicalBucketedHashAggregate<? extends Plan> aggregate,
- PlanTranslatorContext context) {
-
- PlanFragment inputPlanFragment = aggregate.child(0).accept(this,
context);
-
- List<Expression> groupByExpressions =
aggregate.getGroupByExpressions();
- List<NamedExpression> outputExpressions =
aggregate.getOutputExpressions();
-
- // 1. generate slot reference for each group expression
- List<SlotReference> groupSlots =
collectGroupBySlots(groupByExpressions, outputExpressions);
- ArrayList<Expr> execGroupingExpressions =
translateGroupByExprs(groupByExpressions, context);
-
- // 2. collect agg expressions and generate agg function to slot
reference map
- // Mirror SessionVarGuardExpr handling from
visitPhysicalHashAggregate: if an aggregate
- // output is wrapped by SessionVarGuardExpr, translate the guard
(which preserves
- // session-sensitive type behavior) rather than the inner
AggregateExpression directly.
- List<Slot> aggFunctionOutput = Lists.newArrayList();
- ArrayList<FunctionCallExpr> execAggregateFunctions =
Lists.newArrayListWithCapacity(outputExpressions.size());
- Set<AggregateExpression> processedAggregateExpressions =
Sets.newIdentityHashSet();
- for (NamedExpression o : outputExpressions) {
- if (o.containsType(AggregateExpression.class)) {
- aggFunctionOutput.add(o.toSlot());
-
- o.foreach(c -> {
- if (c instanceof SessionVarGuardExpr) {
- SessionVarGuardExpr guardExpr = (SessionVarGuardExpr)
c;
- if (guardExpr.child() instanceof AggregateExpression) {
- AggregateExpression aggregateExpression =
(AggregateExpression) guardExpr.child();
- if
(processedAggregateExpressions.add(aggregateExpression)) {
- execAggregateFunctions.add(
- (FunctionCallExpr)
ExpressionTranslator.translate(guardExpr, context)
- );
- }
- }
- return true;
- }
- if (c instanceof AggregateExpression) {
- AggregateExpression aggregateExpression =
(AggregateExpression) c;
- if
(processedAggregateExpressions.add(aggregateExpression)) {
- execAggregateFunctions.add(
- (FunctionCallExpr)
ExpressionTranslator.translate(aggregateExpression, context)
- );
- }
- return true;
- }
- return false;
- });
- }
- }
-
- // 3. generate output tuple
- Pair<TupleDescriptor, List<Integer>> tupleAndIds =
- buildAggOutputTuple(groupSlots, aggFunctionOutput, context);
- TupleDescriptor outputTupleDesc = tupleAndIds.first;
- List<Integer> aggFunOutputIds = tupleAndIds.second;
-
- // Bucketed agg uses AggPhase.FIRST (update semantics): raw input ->
final result.
- // Not partial — always needsFinalize.
- AggregateInfo aggInfo = AggregateInfo.create(execGroupingExpressions,
execAggregateFunctions,
- aggFunOutputIds, false /* isPartial */, outputTupleDesc,
- AggregateInfo.AggPhase.FIRST);
-
- BucketedAggregationNode bucketedAggNode = new BucketedAggregationNode(
- context.nextPlanNodeId(), inputPlanFragment.getPlanRoot(),
aggInfo, true /* needsFinalize */);
-
- bucketedAggNode.setNereidsId(aggregate.getId());
- context.getNereidsIdToPlanNodeIdMap().put(aggregate.getId(),
bucketedAggNode.getId());
-
- // Bucketed agg runs entirely within a single fragment. No exchange
needed.
- // Do NOT set hasColocatePlanNode — bucketed agg does not require
colocate
- // semantics (one-instance-per-bucket). Bucket assignment is done at
the BE
- // level via hash partitioning. Leaving this unset allows the fragment
to
- // route through UnassignedScanSingleOlapTableJob, which respects
- // parallel_pipeline_task_num as an upper bound on parallelism.
-
- setPlanRoot(inputPlanFragment, bucketedAggNode, aggregate);
- if (aggregate.getStats() != null) {
- bucketedAggNode.setCardinality((long)
aggregate.getStats().getRowCount());
- }
- updateLegacyPlanIdToPhysicalPlan(inputPlanFragment.getPlanRoot(),
aggregate);
-
- return inputPlanFragment;
- }
-
@Override
public PlanFragment visitPhysicalStorageLayerAggregate(
PhysicalStorageLayerAggregate storageLayerAggregate,
PlanTranslatorContext context) {
@@ -1675,8 +1561,15 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
PhysicalHashJoin<PhysicalPlan, PhysicalPlan> physicalHashJoin
= (PhysicalHashJoin<PhysicalPlan, PhysicalPlan>) hashJoin;
// NOTICE: We must visit from right to left, to ensure the last
fragment is root fragment
- PlanFragment rightFragment = hashJoin.child(1).accept(this, context);
- PlanFragment leftFragment = hashJoin.child(0).accept(this, context);
+ context.enterFragmentMergeChild();
+ PlanFragment rightFragment;
+ PlanFragment leftFragment;
+ try {
+ rightFragment = hashJoin.child(1).accept(this, context);
+ leftFragment = hashJoin.child(0).accept(this, context);
+ } finally {
+ context.exitFragmentMergeChild();
+ }
List<List<Expr>> distributeExprLists
= getDistributeExprs(physicalHashJoin.left(),
physicalHashJoin.right());
@@ -1947,8 +1840,15 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
// TODO: we should add a helper method to wrap this logic.
// Maybe something like private List<PlanFragment>
postOrderVisitChildren(
// PhysicalPlan plan, PlanVisitor visitor, Context context).
- PlanFragment rightFragment = nestedLoopJoin.child(1).accept(this,
context);
- PlanFragment leftFragment = nestedLoopJoin.child(0).accept(this,
context);
+ context.enterFragmentMergeChild();
+ PlanFragment rightFragment;
+ PlanFragment leftFragment;
+ try {
+ rightFragment = nestedLoopJoin.child(1).accept(this, context);
+ leftFragment = nestedLoopJoin.child(0).accept(this, context);
+ } finally {
+ context.exitFragmentMergeChild();
+ }
List<List<Expr>> distributeExprLists
= getDistributeExprs(nestedLoopJoin.child(0),
nestedLoopJoin.child(1));
PlanNode leftFragmentPlanRoot = leftFragment.getPlanRoot();
@@ -2443,8 +2343,13 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
public PlanFragment visitPhysicalSetOperation(
PhysicalSetOperation setOperation, PlanTranslatorContext context) {
List<PlanFragment> childrenFragments = new ArrayList<>();
- for (Plan plan : setOperation.children()) {
- childrenFragments.add(plan.accept(this, context));
+ context.enterFragmentMergeChild();
+ try {
+ for (Plan plan : setOperation.children()) {
+ childrenFragments.add(plan.accept(this, context));
+ }
+ } finally {
+ context.exitFragmentMergeChild();
}
List<List<Expr>> distributeExprLists =
getDistributeExprs(setOperation.children().toArray(new Plan[0]));
TupleDescriptor setTuple = generateTupleDesc(setOperation.getOutput(),
null, context);
@@ -3217,9 +3122,271 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
return leftFragment;
}
+ /**
+ * Check whether the one-phase GLOBAL hash aggregate can be fused with its
+ * distribute child into a BucketedAggregationNode. This eliminates
exchange
+ * overhead on single-BE deployments by using in-memory per-bucket merging.
+ */
+ private boolean shouldUseBucketedFusion(PhysicalHashAggregate<? extends
Plan> aggregate,
+ PlanTranslatorContext context) {
+ // Shared eligibility: session var, single-BE, GROUP BY, smooth upgrade
+ if
(!AggregateUtils.isBucketedHashAggEnabled(aggregate.getGroupByExpressions().size()))
{
+ return false;
+ }
+ // Must be one-phase: GLOBAL + INPUT_TO_RESULT
+ if (aggregate.getAggPhase() != AggPhase.GLOBAL
+ || aggregate.getAggMode() != AggMode.INPUT_TO_RESULT) {
+ return false;
+ }
+ // Exclude one-phase-only aggregates (e.g. GROUP_CONCAT with ORDER BY).
+ // BucketedAggregationNode has no sort-info field, so fusing would drop
+ // the aggregate ORDER BY contract. Only aggregates supporting
two-phase
+ // execution can be safely fused.
+ if (!supportsTwoPhaseAgg(aggregate)) {
+ return false;
+ }
+ // BucketedAggregationNode does not support sortByGroupKey
(PushTopnToAgg
+ // optimization). Regular AggregationNode fills sort info; fusing
would drop it.
+ if (aggregate.getTopnPushInfo() != null) {
+ return false;
+ }
+ // Child must be PhysicalDistribute with hash distribution matching
group keys
+ Plan child = aggregate.child(0);
+ if (!(child instanceof PhysicalDistribute)) {
+ return false;
+ }
+ // Bucketed fusion bypasses the distribute/exchange and builds
directly on the
+ // child fragment. When the child subtree contains a CTE consumer
(materialized
+ // multicast CTE), the child fragment is the MultiCastPlanFragment; a
parent
+ // distribute would then treat the aggregate output slots as consumer
slots and
+ // fail with "Required producer slot ... doesn't exist". Fall back to
the
+ // regular one-phase path (which keeps the exchange) for such plans.
+ if (containsCTEConsumer(child)) {
+ return false;
+ }
+ // The distribute's child subtree must be a unary pipeline over
exactly one
+ // olap scan. Fusing an aggregate whose input contains a join / set-op
/ CTE
+ // subtree would leave multiple olap scans in a single fragment
(rejected by
+ // UnassignedJobBuilder: "Not supported multiple scan multiple
OlapTable but
+ // not contains colocate join or bucket shuffle join"), and fusing
over a
+ // nested aggregate would break the bucket alignment between stages.
+ if (!isSingleOlapScanPipeline(aggregate.child(0).child(0))) {
+ return false;
+ }
+ // The parent is a fragment-merging node (join / set-op) that consumes
this
+ // fragment without an exchange boundary: fusing removes the exchange
that
+ // keeps the scan in its own fragment, so multiple scans would end up
in the
+ // same fragment and the scan-assignment would fail. Only fuse when the
+ // parent chain keeps an exchange boundary (e.g. a top-level
aggregate).
+ if (context.isInFragmentMergeChild()) {
+ return false;
+ }
+ DistributionSpec distSpec = ((PhysicalDistribute<?>)
child).getDistributionSpec();
+ if (!(distSpec instanceof DistributionSpecHash)) {
+ return false;
+ }
+ List<ExprId> distKeys = ((DistributionSpecHash)
distSpec).getOrderedShuffledColumns();
+ List<ExprId> groupByKeys = aggregate.getGroupByExpressions().stream()
+ .filter(SlotReference.class::isInstance)
+ .map(SlotReference.class::cast)
+ .map(SlotReference::getExprId)
+ .collect(Collectors.toList());
+ return distKeys.equals(groupByKeys);
+ }
+
+ /** Returns true if the plan subtree contains a physical CTE consumer. */
+ private boolean containsCTEConsumer(Plan plan) {
+ if (plan instanceof PhysicalCTEConsumer) {
+ return true;
+ }
+ for (Plan child : plan.children()) {
+ if (containsCTEConsumer(child)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if the plan subtree is a unary pipeline over exactly one
olap
+ * scan, i.e. it translates into a single-scan fragment that bucketed
fusion
+ * can safely build upon. Subtrees containing fragment-merging or
+ * distribution-changing nodes (join / set-op / CTE / nested aggregate /
+ * storage-layer aggregate) are rejected.
+ */
+ private boolean isSingleOlapScanPipeline(Plan plan) {
+ if (plan instanceof PhysicalOlapScan) {
+ return true;
+ }
+ if (plan instanceof PhysicalHashJoin
+ || plan instanceof PhysicalNestedLoopJoin
+ || plan instanceof PhysicalSetOperation
+ || plan instanceof PhysicalCTEConsumer
+ || plan instanceof PhysicalCTEAnchor
+ || plan instanceof PhysicalHashAggregate
+ || plan instanceof PhysicalStorageLayerAggregate) {
+ return false;
+ }
+ if (plan.children().size() == 1) {
+ return isSingleOlapScanPipeline(plan.child(0));
+ }
+ return false;
+ }
+
+ /**
+ * Check whether all aggregate functions in this physical hash aggregate
+ * support two-phase execution. One-phase-only aggregates (e.g.
GROUP_CONCAT
+ * with ORDER BY) cannot be bucketed because BucketedAggregationNode does
not
+ * carry sort-info metadata (aggSortInfos); fusing them would drop the
+ * aggregate ORDER BY contract and produce unordered results.
+ */
+ private boolean supportsTwoPhaseAgg(PhysicalHashAggregate<? extends Plan>
aggregate) {
+ for (NamedExpression o : aggregate.getOutputExpressions()) {
+ AtomicBoolean foundOnePhaseOnly = new AtomicBoolean(false);
+ o.foreach(c -> {
+ if (c instanceof OrderExpression) {
+ // Any aggregate function with an internal ORDER BY
+ // (e.g. GROUP_CONCAT(... ORDER BY ...)) needs sort-info
+ // metadata, which BucketedAggregationNode does not carry.
+ foundOnePhaseOnly.set(true);
+ return false;
+ }
+ if (c instanceof AggregateExpression) {
+ AggregateFunction func = ((AggregateExpression)
c).getFunction();
+ if (!func.supportAggregatePhase(AggregatePhase.TWO)) {
+ foundOnePhaseOnly.set(true);
+ }
+ return true;
+ }
+ return false;
+ });
+ if (foundOnePhaseOnly.get()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Fuse a one-phase GLOBAL hash aggregate and its PhysicalDistribute child
+ * into a BucketedAggregationNode, skipping the exchange node entirely.
+ * Visits the distribute's child directly to keep everything in one
fragment.
+ */
+ private PlanFragment visitBucketedFusion(
+ PhysicalHashAggregate<? extends Plan> aggregate,
+ PlanTranslatorContext context) {
+ // Visit the distribute's direct child, bypassing the distribute
entirely.
+ // This avoids creating an ExchangeNode that bucketed agg does not
need.
+ Plan distributeChild = aggregate.child(0).child(0);
+ PlanFragment inputPlanFragment = distributeChild.accept(this, context);
+
+ List<Expression> groupByExpressions =
aggregate.getGroupByExpressions();
+ List<NamedExpression> outputExpressions =
aggregate.getOutputExpressions();
+
+ // 1. generate slot reference for each group expression
+ List<SlotReference> groupSlots =
collectGroupBySlots(groupByExpressions, outputExpressions);
+ ArrayList<Expr> execGroupingExpressions =
translateGroupByExprs(groupByExpressions, context);
+
+ // 2. collect agg expressions and generate agg function to slot
reference map.
+ // Reuse the shared helper from visitPhysicalHashAggregate; the
bucketed
+ // path passes null for hasPartialOut (never partial, always
needsFinalize).
+ Pair<List<Slot>, ArrayList<FunctionCallExpr>> aggResult =
+ collectAggFunctions(outputExpressions, null, context);
+ List<Slot> aggFunctionOutput = aggResult.first;
+ ArrayList<FunctionCallExpr> execAggregateFunctions = aggResult.second;
+
+ // 3. generate output tuple
+ Pair<TupleDescriptor, List<Integer>> tupleAndIds =
+ buildAggOutputTuple(groupSlots, aggFunctionOutput, context);
+ TupleDescriptor outputTupleDesc = tupleAndIds.first;
+ List<Integer> aggFunOutputIds = tupleAndIds.second;
+
+ // Bucketed agg uses AggPhase.FIRST (update semantics): raw input ->
final result.
+ // Not partial — always needsFinalize.
+ AggregateInfo aggInfo = AggregateInfo.create(execGroupingExpressions,
execAggregateFunctions,
+ aggFunOutputIds, false /* isPartial */, outputTupleDesc,
+ AggregateInfo.AggPhase.FIRST);
+
+ BucketedAggregationNode bucketedAggNode = new BucketedAggregationNode(
+ context.nextPlanNodeId(), inputPlanFragment.getPlanRoot(),
aggInfo, true);
+
+ bucketedAggNode.setNereidsId(aggregate.getId());
+ context.getNereidsIdToPlanNodeIdMap().put(aggregate.getId(),
bucketedAggNode.getId());
+
+ // Do NOT set hasColocatePlanNode — bucketed agg uses its own
hash-based
+ // bucket assignment, not colocate semantics. This allows the fragment
to
+ // route through UnassignedScanSingleOlapTableJob, which respects
+ // parallel_pipeline_task_num as an upper bound on parallelism.
+ setPlanRoot(inputPlanFragment, bucketedAggNode, aggregate);
+ if (aggregate.getStats() != null) {
+ bucketedAggNode.setCardinality((long)
aggregate.getStats().getRowCount());
+ }
+ updateLegacyPlanIdToPhysicalPlan(inputPlanFragment.getPlanRoot(),
aggregate);
+ return inputPlanFragment;
+ }
+
+ /**
+ * Collect aggregate function outputs and translate them to legacy
FunctionCallExpr.
+ * Shared by visitPhysicalHashAggregate and visitBucketedFusion.
+ *
+ * @param hasPartialOut if non-null and length >= 1, hasPartialOut[0] is
set to
+ * true when any aggregate function produces a buffer (i.e. is
partial).
+ * The bucketed path passes null.
+ */
+ private Pair<List<Slot>, ArrayList<FunctionCallExpr>> collectAggFunctions(
+ List<NamedExpression> outputExpressions,
+ boolean[] hasPartialOut,
+ PlanTranslatorContext context) {
+ List<Slot> aggFunctionOutput = Lists.newArrayList();
+ ArrayList<FunctionCallExpr> execAggregateFunctions =
+ Lists.newArrayListWithCapacity(outputExpressions.size());
+ Set<AggregateExpression> processed = Sets.newIdentityHashSet();
+ for (NamedExpression o : outputExpressions) {
+ if (o.containsType(AggregateExpression.class)) {
+ aggFunctionOutput.add(o.toSlot());
+ collectAggInTree(o, processed, execAggregateFunctions,
hasPartialOut, context);
+ }
+ }
+ return Pair.of(aggFunctionOutput, execAggregateFunctions);
+ }
+
+ /** Walk the expression tree to find and translate AggregateExpression
nodes. */
+ private void collectAggInTree(Expression expr,
+ Set<AggregateExpression> processed,
+ ArrayList<FunctionCallExpr> out,
+ boolean[] hasPartialOut,
+ PlanTranslatorContext context) {
+ if (expr instanceof SessionVarGuardExpr) {
+ SessionVarGuardExpr guard = (SessionVarGuardExpr) expr;
+ if (guard.child() instanceof AggregateExpression) {
+ AggregateExpression ae = (AggregateExpression) guard.child();
+ if (processed.add(ae)) {
+ out.add((FunctionCallExpr)
ExpressionTranslator.translate(guard, context));
+ if (hasPartialOut != null) {
+ hasPartialOut[0] |=
ae.getAggregateParam().aggMode.productAggregateBuffer;
+ }
+ }
+ }
+ return;
+ }
+ if (expr instanceof AggregateExpression) {
+ AggregateExpression ae = (AggregateExpression) expr;
+ if (processed.add(ae)) {
+ out.add((FunctionCallExpr) ExpressionTranslator.translate(ae,
context));
+ if (hasPartialOut != null) {
+ hasPartialOut[0] |=
ae.getAggregateParam().aggMode.productAggregateBuffer;
+ }
+ }
+ return;
+ }
+ for (Expression child : expr.children()) {
+ collectAggInTree(child, processed, out, hasPartialOut, context);
+ }
+ }
+
/**
* Translate group-by expressions from Nereids Expression to legacy Expr.
- * Shared by visitPhysicalHashAggregate and
visitPhysicalBucketedHashAggregate.
+ * Shared by visitPhysicalHashAggregate.
*/
private ArrayList<Expr> translateGroupByExprs(List<Expression>
groupByExpressions,
PlanTranslatorContext context) {
@@ -3237,7 +3404,7 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
/**
* Build output tuple descriptor and aggregate function output slot IDs.
* Returns Pair(outputTupleDesc, aggFunOutputIds).
- * Shared by visitPhysicalHashAggregate and
visitPhysicalBucketedHashAggregate.
+ * Shared by visitPhysicalHashAggregate.
*/
private Pair<TupleDescriptor, List<Integer>> buildAggOutputTuple(
List<SlotReference> groupSlots, List<Slot> aggFunctionOutput,
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PlanTranslatorContext.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PlanTranslatorContext.java
index 936f4cbe1a9..0a09cd0670b 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PlanTranslatorContext.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PlanTranslatorContext.java
@@ -111,6 +111,18 @@ public class PlanTranslatorContext {
private final Map<ScanNode, Set<SlotId>> statsUnknownColumnsMap =
Maps.newHashMap();
+ /**
+ * Depth of fragment-merging binary nodes (hash join / nested loop join /
+ * set operation) whose children are being visited right now. Bucketed
fusion
+ * removes the exchange node that would otherwise keep an olap scan in its
own
+ * fragment; when the fused fragment is consumed by such a node the scan
gets
+ * merged into a fragment that already contains other scans, which the
+ * scan-assignment jobs reject ("Not supported multiple scan multiple
+ * OlapTable but not contains colocate join or bucket shuffle join"). The
+ * translator therefore skips bucketed fusion while inside a merge child.
+ */
+ private int fragmentMergeChildDepth = 0;
+
// Per-node "is there a serial operator between me and the pipeline's
sink" flag.
// Mirrors BE's any_of(operators[idx..end], is_serial_operator) check used
by
// _add_local_exchange / need_to_local_exchange to skip LE insertion when
an ancestor
@@ -325,6 +337,18 @@ public class PlanTranslatorContext {
exprIdToColumnRef.put(exprId, columnRefExpr);
}
+ public void enterFragmentMergeChild() {
+ fragmentMergeChildDepth++;
+ }
+
+ public void exitFragmentMergeChild() {
+ fragmentMergeChildDepth--;
+ }
+
+ public boolean isInFragmentMergeChild() {
+ return fragmentMergeChildDepth > 0;
+ }
+
/**
* merge source fragment info into target fragment.
* include runtime filter info and fragment attribute.
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/ProjectAggregateExpressionsForCse.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/ProjectAggregateExpressionsForCse.java
index c9bb5c6a7c8..a77640a55dc 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/ProjectAggregateExpressionsForCse.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/ProjectAggregateExpressionsForCse.java
@@ -32,7 +32,6 @@ import
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunctio
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.algebra.Aggregate;
import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalPlan;
-import
org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalDistribute;
import org.apache.doris.nereids.trees.plans.physical.PhysicalHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalPlan;
@@ -61,16 +60,9 @@ public class ProjectAggregateExpressionsForCse extends
PlanPostProcessor {
(PhysicalHashAggregate<? extends Plan>) super.visit(aggregate,
ctx));
}
- @Override
- public Plan visitPhysicalBucketedHashAggregate(
- PhysicalBucketedHashAggregate<? extends Plan> aggregate,
CascadesContext ctx) {
- return projectAggregateCse(
- (PhysicalBucketedHashAggregate<? extends Plan>)
super.visit(aggregate, ctx));
- }
-
/**
- * Shared CSE projection logic for both PhysicalHashAggregate and
- * PhysicalBucketedHashAggregate. Extracts common sub-expressions from
+ * Shared CSE projection logic for PhysicalHashAggregate.
+ * Extracts common sub-expressions from
* aggregate function arguments into a project node beneath the aggregate.
*/
private <T extends AbstractPhysicalPlan & Aggregate<? extends Plan>>
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/RuntimeFilterPruner.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/RuntimeFilterPruner.java
index 42df8b9e7c8..ed0a1d17a94 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/RuntimeFilterPruner.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/RuntimeFilterPruner.java
@@ -26,7 +26,6 @@ import
org.apache.doris.nereids.trees.expressions.SlotReference;
import org.apache.doris.nereids.trees.plans.AbstractPlan;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.physical.PhysicalAssertNumRows;
-import
org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEAnchor;
import org.apache.doris.nereids.trees.plans.physical.PhysicalFilter;
import org.apache.doris.nereids.trees.plans.physical.PhysicalHashAggregate;
@@ -242,15 +241,9 @@ public class RuntimeFilterPruner extends PlanPostProcessor
{
return propagateEffectiveSrc(aggregate, context);
}
- @Override
- public PhysicalBucketedHashAggregate visitPhysicalBucketedHashAggregate(
- PhysicalBucketedHashAggregate<? extends Plan> aggregate,
CascadesContext context) {
- return propagateEffectiveSrc(aggregate, context);
- }
-
/**
* Visit child and propagate effective source type if applicable.
- * Shared by visitPhysicalHashAggregate and
visitPhysicalBucketedHashAggregate.
+ * Shared by visitPhysicalHashAggregate.
*
* Note: agg is not regarded as an effective source itself. For example:
* q1: A join (select x, sum(y) as z from B group by x) T on A.a = T.x
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/materialize/LazySlotPruning.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/materialize/LazySlotPruning.java
index 3273a7d82d3..fce964758f1 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/materialize/LazySlotPruning.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/materialize/LazySlotPruning.java
@@ -24,7 +24,6 @@ import
org.apache.doris.nereids.trees.expressions.SlotReference;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalJoin;
import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalPlan;
-import
org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEConsumer;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEProducer;
import org.apache.doris.nereids.trees.plans.physical.PhysicalFileScan;
@@ -213,12 +212,6 @@ public class LazySlotPruning extends
DefaultPlanRewriter<LazySlotPruning.Context
return aggregate;
}
- @Override
- public Plan visitPhysicalBucketedHashAggregate(
- PhysicalBucketedHashAggregate<? extends Plan> aggregate, Context
context) {
- return aggregate;
- }
-
@Override
public Plan visitPhysicalCTEConsumer(PhysicalCTEConsumer cteConsumer,
Context context) {
return cteConsumer;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java
index 823231aa317..2df7723a7ab 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java
@@ -29,10 +29,10 @@ import
org.apache.doris.nereids.trees.expressions.NamedExpression;
import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.SlotReference;
import
org.apache.doris.nereids.trees.expressions.functions.table.TableValuedFunction;
+import org.apache.doris.nereids.trees.plans.AggMode;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalSort;
import org.apache.doris.nereids.trees.plans.physical.PhysicalAssertNumRows;
-import
org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEAnchor;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEConsumer;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEProducer;
@@ -61,6 +61,7 @@ import
org.apache.doris.nereids.trees.plans.physical.PhysicalWindow;
import
org.apache.doris.nereids.trees.plans.physical.PhysicalWorkTableReference;
import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
import org.apache.doris.nereids.types.DataType;
+import org.apache.doris.nereids.util.AggregateUtils;
import org.apache.doris.nereids.util.JoinUtils;
import org.apache.doris.nereids.util.Utils;
import org.apache.doris.qe.ConnectContext;
@@ -191,22 +192,36 @@ public class ChildOutputPropertyDeriver extends
PlanVisitor<PhysicalProperties,
case GLOBAL:
case DISTINCT_LOCAL:
case DISTINCT_GLOBAL:
+ // Bucketed hash agg fusion: when the one-phase GLOBAL
aggregate
+ // will be fused with its distribute child into
BucketedAggregationNode,
+ // the output is NOT hash-distributed (256-bucket internal
hash is
+ // not shuffle-compatible). Advertise ANY to prevent parent
operators
+ // from incorrectly skipping exchanges.
+ if (agg.getAggPhase().isGlobal()
+ && agg.getAggMode() == AggMode.INPUT_TO_RESULT
+ && AggregateUtils.isBucketedHashAggEnabled(
+ agg.getGroupByExpressions().size())
+ &&
isShuffleCompatible(childOutputProperty.getDistributionSpec())) {
+ return PhysicalProperties.ANY;
+ }
return new
PhysicalProperties(childOutputProperty.getDistributionSpec());
default:
throw new RuntimeException("Could not derive output properties
for agg phase: " + agg.getAggPhase());
}
}
- @Override
- public PhysicalProperties visitPhysicalBucketedHashAggregate(
- PhysicalBucketedHashAggregate<? extends Plan> agg, PlanContext
context) {
- Preconditions.checkState(childrenOutputProperties.size() == 1);
- // Although bucketed agg internally re-distributes data into 256
buckets by
- // group-by keys (so the output is "complete" per group), we cannot
claim
- // EXECUTION_BUCKETED distribution because the 256-bucket hash
function differs
- // from the shuffle hash function. Downstream operators expecting
shuffle-compatible
- // distribution would be incorrect. Preserve distribution ANY.
- return PhysicalProperties.ANY;
+ /**
+ * Returns true if the child's distribution is a shuffle-compatible hash
that the
+ * bucketed fusion pattern produces (ShuffleType.REQUIRE).
EXECUTION_BUCKETED is
+ * used by CTE dedup and colocate-join patterns — those should NOT be
treated as
+ * bucketed-fusion output because their hash functions ARE
shuffle-compatible.
+ */
+ private static boolean isShuffleCompatible(DistributionSpec distSpec) {
+ if (!(distSpec instanceof DistributionSpecHash)) {
+ return false;
+ }
+ DistributionSpecHash hashSpec = (DistributionSpecHash) distSpec;
+ return hashSpec.getShuffleType() == ShuffleType.REQUIRE;
}
@Override
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java
index 6aa9d01c7cc..e94e4f7dfae 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java
@@ -159,6 +159,13 @@ public class ChildrenPropertiesRegulator extends
PlanVisitor<List<List<PhysicalP
// group by key is skew
return skewOnShuffleExpr(aggregate);
} else {
+ // Bucketed hash agg exception: allow one-phase GLOBAL + distribute
+ // pattern so the translator can fuse it into
BucketedAggregationNode.
+ // Gate with data-volume checks using group-level statistics to
avoid
+ // generating this pattern when bucketed agg is unsuitable.
+ if
(AggregateUtils.isBucketedHashAggEnabled(aggregate.getGroupByExpressions().size()))
{
+ return !bucketedDataVolumeGatesPass(aggregate);
+ }
return true;
}
}
@@ -218,6 +225,55 @@ public class ChildrenPropertiesRegulator extends
PlanVisitor<List<List<PhysicalP
&& children.get(0).getPlan() instanceof PhysicalDistribute;
}
+ /**
+ * Check data-volume gates for bucketed hash aggregation using group-level
+ * statistics available during property regulation. Returns true if the
+ * pattern should be allowed (stats pass or unavailable), false if it
should
+ * be banned due to unfavorable data characteristics.
+ * Mirrors the checks from the old implementBucketedPhase.
+ */
+ private boolean bucketedDataVolumeGatesPass(PhysicalHashAggregate<?
extends Plan> aggregate) {
+ Statistics inputStats =
aggregate.getGroupExpression().get().childStatistics(0);
+ if (inputStats == null) {
+ return true; // no stats → allow (other gates handle eligibility)
+ }
+ Statistics outputStats = aggregate.getGroupExpression().get()
+ .getOwnerGroup().getStatistics();
+ SessionVariable sv = ConnectContext.get().getSessionVariable();
+ double rows = inputStats.getRowCount();
+
+ // Gate 1: minimum input rows
+ if (sv.bucketedAggMinInputRows > 0 && rows <
sv.bucketedAggMinInputRows) {
+ return false;
+ }
+
+ // Gate 2: high-cardinality GROUP BY columns
+ double highCardThreshold = sv.bucketedAggHighCardThreshold;
+ if (highCardThreshold > 0) {
+ for (Expression groupByKey : aggregate.getGroupByExpressions()) {
+ ColumnStatistic colStat =
inputStats.findColumnStatistics(groupByKey);
+ if (colStat != null && !colStat.isUnKnown
+ && colStat.ndv > rows * highCardThreshold) {
+ return false;
+ }
+ }
+ }
+
+ // Gate 3: max group keys (merge phase cost dominates)
+ if (sv.bucketedAggMaxGroupKeys > 0 && outputStats != null
+ && outputStats.getRowCount() > sv.bucketedAggMaxGroupKeys) {
+ return false;
+ }
+
+ // Gate 4: aggregation output cardinality ratio
+ if (highCardThreshold > 0 && outputStats != null
+ && outputStats.getRowCount() > rows * highCardThreshold) {
+ return false;
+ }
+
+ return true;
+ }
+
private boolean childIsCTEConsumer() {
List<GroupExpression> groupExpressions =
children.get(0).children().get(0).getPhysicalExpressions();
if (groupExpressions != null && !groupExpressions.isEmpty()) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java
index 8f6f236dd1b..2a94a6c971b 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java
@@ -39,7 +39,6 @@ import
org.apache.doris.nereids.trees.plans.algebra.SetOperation;
import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalSort;
import org.apache.doris.nereids.trees.plans.physical.PhysicalAssertNumRows;
import org.apache.doris.nereids.trees.plans.physical.PhysicalBlackholeSink;
-import
org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEAnchor;
import
org.apache.doris.nereids.trees.plans.physical.PhysicalConnectorTableSink;
import org.apache.doris.nereids.trees.plans.physical.PhysicalDictionarySink;
@@ -505,14 +504,6 @@ public class RequestPropertyDeriver extends
PlanVisitor<Void, PlanContext> {
return null;
}
- @Override
- public Void visitPhysicalBucketedHashAggregate(
- PhysicalBucketedHashAggregate<? extends Plan> agg, PlanContext
context) {
- // Bucketed agg runs entirely on a single BE — no exchange needed.
- addRequestPropertyToChildren(PhysicalProperties.ANY);
- return null;
- }
-
private boolean shouldUseParent(List<ExprId> parentHashExprIds,
PhysicalHashAggregate<? extends Plan> agg,
PlanContext context) {
if
(!context.getConnectContext().getSessionVariable().aggShuffleUseParentKey) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/SplitAggWithoutDistinct.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/SplitAggWithoutDistinct.java
index fcadf6b3c70..324e1833f79 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/SplitAggWithoutDistinct.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/SplitAggWithoutDistinct.java
@@ -17,53 +17,32 @@
package org.apache.doris.nereids.rules.implementation;
-import org.apache.doris.catalog.Column;
-import org.apache.doris.catalog.DistributionInfo;
-import org.apache.doris.catalog.HashDistributionInfo;
-import org.apache.doris.catalog.OlapTable;
-import org.apache.doris.nereids.memo.Group;
-import org.apache.doris.nereids.memo.GroupExpression;
import org.apache.doris.nereids.rules.Rule;
import org.apache.doris.nereids.rules.RuleType;
import org.apache.doris.nereids.trees.expressions.AggregateExpression;
import org.apache.doris.nereids.trees.expressions.Alias;
-import org.apache.doris.nereids.trees.expressions.ExprId;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
import org.apache.doris.nereids.trees.expressions.SessionVarGuardExpr;
-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.AggregateFunction;
import org.apache.doris.nereids.trees.expressions.functions.agg.AggregateParam;
import org.apache.doris.nereids.trees.expressions.functions.agg.AggregatePhase;
-import
org.apache.doris.nereids.trees.expressions.functions.agg.MultiDistinction;
import
org.apache.doris.nereids.trees.expressions.visitor.DefaultExpressionVisitor;
import org.apache.doris.nereids.trees.plans.AggMode;
import org.apache.doris.nereids.trees.plans.AggPhase;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.algebra.Aggregate;
import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate;
-import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
-import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
-import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
-import org.apache.doris.nereids.trees.plans.logical.LogicalTopN;
-import
org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalHashAggregate;
import org.apache.doris.nereids.util.AggregateUtils;
import org.apache.doris.nereids.util.ExpressionUtils;
import org.apache.doris.qe.ConnectContext;
-import org.apache.doris.statistics.ColumnStatistic;
-import org.apache.doris.statistics.Statistics;
-import org.apache.doris.system.Backend;
-import org.apache.doris.system.SystemInfoService;
import com.google.common.collect.ImmutableList;
import java.util.HashMap;
-import java.util.HashSet;
import java.util.List;
import java.util.Map;
-import java.util.Set;
/**SplitAgg
* only process agg without distinct function, split Agg into 2 phase: local
agg and global agg
@@ -94,11 +73,6 @@ public class SplitAggWithoutDistinct extends
OneImplementationRuleFactory {
boolean singleExecutionInstance =
AggregateUtils.isSingleExecutionInstance(ctx);
if (!singleExecutionInstance || onePhaseCandidates.isEmpty()) {
candidates.addAll(splitTwoPhase(aggregate));
- // Only add bucketed agg candidate in auto mode (aggPhase
== 0).
- // When the user forces a specific phase, respect that
choice.
- if (!singleExecutionInstance) {
- candidates.addAll(implementBucketedPhase(aggregate,
ctx));
- }
}
break;
}
@@ -193,306 +167,6 @@ public class SplitAggWithoutDistinct extends
OneImplementationRuleFactory {
aggregate.getLogicalProperties(),
aggregate.getSourceRepeat().isPresent(), localAgg));
}
- /**
- * Implements bucketed hash aggregation for single-BE deployments.
- * Fuses two-phase aggregation into a single PhysicalBucketedHashAggregate
operator,
- * eliminating exchange overhead and serialization/deserialization costs.
- *
- * Only generated when:
- * 1. enable_bucketed_hash_agg session variable is true
- * 2. Cluster has exactly one alive BE
- * 3. Aggregate has GROUP BY keys (no without-key aggregation)
- * 4. Aggregate functions support two-phase execution
- * 5. Data volume checks pass (min input rows, max group keys)
- */
- private List<Plan> implementBucketedPhase(LogicalAggregate<? extends Plan>
aggregate, ConnectContext ctx) {
- if (!ctx.getSessionVariable().enableBucketedHashAgg) {
- return ImmutableList.of();
- }
- // Only for single-BE deployments
- int beNumber = ctx.getEnv().getClusterInfo().getBackendsNumber(true);
- if (beNumber != 1) {
- return ImmutableList.of();
- }
- // Skip during smooth upgrade: old BE processes do not recognize the
- // BUCKETED_AGGREGATION_NODE plan node type, so sending such plans
would
- // cause execution failures. Check all alive BEs for the upgrade flag.
- SystemInfoService clusterInfo = ctx.getEnv().getClusterInfo();
- for (Long beId : clusterInfo.getAllBackendByCurrentCluster(true)) {
- Backend be = clusterInfo.getBackend(beId);
- if (be != null && be.isSmoothUpgradeSrc()) {
- return ImmutableList.of();
- }
- }
- // Without-key aggregation not supported in initial version
- if (aggregate.getGroupByExpressions().isEmpty()) {
- return ImmutableList.of();
- }
- // Must support two-phase execution (same check as splitTwoPhase)
- if (!aggregate.supportAggregatePhase(AggregatePhase.TWO)) {
- return ImmutableList.of();
- }
- // Skip aggregates with no aggregate functions (pure GROUP BY dedup).
- // These are produced by DistinctAggregateRewriter as the bottom dedup
phase.
- if (aggregate.getAggregateFunctions().isEmpty()) {
- return ImmutableList.of();
- }
- // Skip aggregates containing multi-distinct functions (e.g.,
multi_distinct_count,
- // multi_distinct_sum). These are semantically distinct aggregations
rewritten by
- // DistinctAggregateRewriter — they embed deduplication in the
BE-level function.
- // The bucketed agg cost model does not account for deduplication
overhead, which
- // causes the base-table bucketed path to appear artificially cheap
compared to
- // materialized views using pre-aggregated bitmap_union/hll_union.
- for (AggregateFunction func : aggregate.getAggregateFunctions()) {
- if (func instanceof MultiDistinction) {
- return ImmutableList.of();
- }
- }
- // Skip aggregates whose child group contains a LogicalAggregate,
indicating
- // a multi-phase decomposition (e.g., COUNT(DISTINCT a) GROUP BY b is
rewritten
- // to COUNT(a) GROUP BY b on top of GROUP BY a,b dedup). These stacked
- // aggregates require cross-phase coordination that bucketed agg does
not support.
- if (childGroupContainsAggregate(aggregate)) {
- return ImmutableList.of();
- }
- // Skip when sortByGroupKey optimization applies. This is detected by
- // checking if the aggregate's owner group has a LogicalTopN parent
- // whose order key expressions equal the group-by keys (produced by
- // LimitAggToTopNAgg rewrite). PhysicalBucketedHashAggregate does not
- // support sortByGroupKey, so we yield to the regular hash-agg plan.
- if (hasSortByGroupKeyTopN(aggregate)) {
- return ImmutableList.of();
- }
- // Skip when data is already distributed by the GROUP BY keys
- // (e.g., table bucketed by UserID, query GROUP BY UserID).
- // In this case the two-phase plan needs no exchange and is strictly
- // better than bucketed agg (no 256-bucket overhead, no merge phase).
- if (groupByKeysSatisfyDistribution(aggregate)) {
- return ImmutableList.of();
- }
- // Data-volume-based checks: control bucketed agg eligibility based on
- // estimated data scale, similar to ClickHouse's
group_by_two_level_threshold
- // and group_by_two_level_threshold_bytes. This reduces reliance on
- // column-level statistics which may be inaccurate or missing.
- //
- // When statistics are unavailable (groupExpression absent or
childStats null),
- // conservatively skip bucketed agg — without data volume information
we cannot
- // make an informed decision, and the risk of choosing bucketed agg in
a
- // high-cardinality scenario outweighs the potential benefit.
- if (!aggregate.getGroupExpression().isPresent()) {
- return ImmutableList.of();
- }
- GroupExpression ge = aggregate.getGroupExpression().get();
- Statistics childStats = ge.childStatistics(0);
- if (childStats == null) {
- return ImmutableList.of();
- }
- double rows = childStats.getRowCount();
- long minInputRows = ctx.getSessionVariable().bucketedAggMinInputRows;
- long maxGroupKeys = ctx.getSessionVariable().bucketedAggMaxGroupKeys;
-
- // Gate: minimum input rows.
- // When input data is too small, the overhead of initializing 256
- // per-bucket hash tables and the pipelined merge phase outweighs
- // the benefit of eliminating exchange. Skip bucketed agg.
- if (minInputRows > 0 && rows < minInputRows) {
- return ImmutableList.of();
- }
-
- // Gate: maximum estimated group keys (similar to ClickHouse's
- // group_by_two_level_threshold). When the number of distinct groups
- // is too large, the source-side merge must combine too many keys
- // across instances, and the merge cost dominates. Skip bucketed agg.
- Statistics aggStats = ge.getOwnerGroup().getStatistics();
- if (maxGroupKeys > 0 && aggStats != null && aggStats.getRowCount() >
maxGroupKeys) {
- return ImmutableList.of();
- }
-
- // High-cardinality ratio checks (existing logic).
- // These complement the absolute thresholds above with relative checks:
- // 1. Single-column NDV check: if ANY GROUP BY key's NDV > rows *
threshold,
- // the combined NDV is at least that high.
- // 2. Aggregation ratio check: if estimated output rows > rows *
threshold,
- // merge cost dominates.
- double highCardThreshold =
ctx.getSessionVariable().bucketedAggHighCardThreshold;
- for (Expression groupByKey : aggregate.getGroupByExpressions()) {
- ColumnStatistic colStat =
childStats.findColumnStatistics(groupByKey);
- if (colStat != null && !colStat.isUnKnown() && colStat.ndv > rows
* highCardThreshold) {
- return ImmutableList.of();
- }
- }
- if (aggStats != null && aggStats.getRowCount() > rows *
highCardThreshold) {
- return ImmutableList.of();
- }
- // Build output expressions: rewrite AggregateFunction ->
AggregateExpression with GLOBAL_RESULT param
- // (same as one-phase aggregation — raw input directly produces final
result).
- List<NamedExpression> aggOutput =
ExpressionUtils.rewriteDownShortCircuit(
- aggregate.getOutputExpressions(), expr -> {
- if (!(expr instanceof AggregateFunction)) {
- return expr;
- }
- return new AggregateExpression((AggregateFunction) expr,
AggregateParam.GLOBAL_RESULT);
- }
- );
- return ImmutableList.of(new PhysicalBucketedHashAggregate<>(
- aggregate.getGroupByExpressions(), aggOutput,
- aggregate.getLogicalProperties(), aggregate.child()));
- }
-
- /**
- * Check if the child group of this aggregate contains a LogicalAggregate.
- * This is used to detect aggregates produced by DISTINCT decomposition
rewrites
- * (e.g., DistinctAggregateRewriter, SplitMultiDistinctStrategy), where
the original
- * DISTINCT aggregate is split into a top non-distinct aggregate over a
bottom dedup aggregate.
- */
- private boolean childGroupContainsAggregate(LogicalAggregate<? extends
Plan> aggregate) {
- if (!aggregate.getGroupExpression().isPresent()) {
- return false;
- }
- GroupExpression groupExpr = aggregate.getGroupExpression().get();
- if (groupExpr.arity() == 0) {
- return false;
- }
- Group childGroup = groupExpr.child(0);
- for (GroupExpression childGroupExpr :
childGroup.getLogicalExpressions()) {
- if (childGroupExpr.getPlan() instanceof LogicalAggregate) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Check if a LogicalTopN parent exists whose order keys are identical to
- * the aggregate's group-by keys. This means PushTopnToAgg will later set
- * sortByGroupKey on PhysicalHashAggregate; bucketed agg doesn't support
- * that optimization so we skip it.
- *
- * Handles both TopN->Agg and TopN->Project->Agg patterns.
- */
- private boolean hasSortByGroupKeyTopN(LogicalAggregate<? extends Plan>
aggregate) {
- if (!aggregate.getGroupExpression().isPresent()) {
- return false;
- }
- List<Expression> groupByKeys = aggregate.getGroupByExpressions();
- Group ownerGroup =
aggregate.getGroupExpression().get().getOwnerGroup();
- for (GroupExpression parentGE :
ownerGroup.getParentGroupExpressions()) {
- Plan parentPlan = parentGE.getPlan();
- if (parentPlan instanceof LogicalTopN
- && AggregateUtils.isOrderKeysMatchGroupKeys(
- ((LogicalTopN<?>) parentPlan).getOrderKeys(),
groupByKeys)) {
- return true;
- }
- if (parentPlan instanceof LogicalProject &&
parentGE.getOwnerGroup() != null) {
- for (GroupExpression gpGE :
parentGE.getOwnerGroup().getParentGroupExpressions()) {
- if (gpGE.getPlan() instanceof LogicalTopN
- && AggregateUtils.isOrderKeysMatchGroupKeys(
- ((LogicalTopN<?>)
gpGE.getPlan()).getOrderKeys(), groupByKeys)) {
- return true;
- }
- }
- }
- }
- return false;
- }
-
- /**
- * Check if the GROUP BY keys of this aggregate are a superset of (or
equal to)
- * the underlying OlapTable's hash distribution columns. When this is true,
- * the data is already correctly partitioned for the aggregation, so the
- * two-phase plan (local + global) requires no exchange and is strictly
better
- * than bucketed agg (no 256-bucket overhead, no merge phase).
- *
- * Traverses the child group in the Memo to find a LogicalOlapScan,
- * then uses ExprId-based matching (consistent with
- * {@link LogicalOlapScanToPhysicalOlapScan#convertDistribution}) to check
- * whether the hash distribution columns are a subset of the GROUP BY keys.
- * For non-OlapTable children, returns false (skip this gating, allow
bucketed agg).
- */
- private boolean groupByKeysSatisfyDistribution(LogicalAggregate<? extends
Plan> aggregate) {
- if (!aggregate.getGroupExpression().isPresent()) {
- return false;
- }
- GroupExpression groupExpr = aggregate.getGroupExpression().get();
- if (groupExpr.arity() == 0) {
- return false;
- }
- LogicalOlapScan olapScan =
findLogicalOlapScanInGroup(groupExpr.child(0), 5);
- if (olapScan == null) {
- return false;
- }
- OlapTable table = olapScan.getTable();
- DistributionInfo distributionInfo = table.getDefaultDistributionInfo();
- if (!(distributionInfo instanceof HashDistributionInfo)) {
- return false;
- }
- List<Column> distributionColumns = ((HashDistributionInfo)
distributionInfo).getDistributionColumns();
- if (distributionColumns.isEmpty()) {
- return false;
- }
- // Map distribution columns to ExprIds via the scan's output slots
- List<Slot> output = olapScan.getOutput();
- Set<ExprId> distributionExprIds = new HashSet<>();
- for (Column column : distributionColumns) {
- boolean found = false;
- for (Slot slot : output) {
- if (slot instanceof SlotReference
- && ((SlotReference)
slot).getOriginalColumn().isPresent()
- && ((SlotReference)
slot).getOriginalColumn().get().getName()
- .equalsIgnoreCase(column.getName())) {
- distributionExprIds.add(slot.getExprId());
- found = true;
- break;
- }
- }
- if (!found) {
- return false;
- }
- }
- // Collect GROUP BY ExprIds
- Set<ExprId> groupByExprIds = new HashSet<>();
- for (Expression expr : aggregate.getGroupByExpressions()) {
- if (expr instanceof SlotReference) {
- groupByExprIds.add(((SlotReference) expr).getExprId());
- }
- }
- return groupByExprIds.containsAll(distributionExprIds);
- }
-
- /**
- * Recursively search through a Memo Group to find a LogicalOlapScan,
- * walking through LogicalProject and LogicalFilter nodes.
- * Returns the LogicalOlapScan if found, null otherwise.
- * maxDepth prevents infinite recursion.
- */
- private LogicalOlapScan findLogicalOlapScanInGroup(Group group, int
maxDepth) {
- if (maxDepth <= 0) {
- return null;
- }
- for (GroupExpression ge : group.getLogicalExpressions()) {
- Plan plan = ge.getPlan();
- if (plan instanceof LogicalOlapScan) {
- return (LogicalOlapScan) plan;
- }
- if ((plan instanceof LogicalProject || plan instanceof
LogicalFilter) && ge.arity() > 0) {
- LogicalOlapScan result =
findLogicalOlapScanInGroup(ge.child(0), maxDepth - 1);
- if (result != null) {
- return result;
- }
- }
- }
- return null;
- }
-
- private boolean shouldUseLocalAgg(LogicalAggregate<? extends Plan>
aggregate) {
- Statistics aggStats =
aggregate.getGroupExpression().get().getOwnerGroup().getStatistics();
- Statistics aggChildStats =
aggregate.getGroupExpression().get().childStatistics(0);
- // if gbyNdv is high, should not use local agg
- double rows = aggChildStats.getRowCount();
- double gbyNdv = aggStats.getRowCount();
- return gbyNdv * 10 < rows;
- }
-
private boolean skipRegulator(LogicalAggregate<? extends Plan> aggregate) {
for (AggregateFunction aggregateFunction :
aggregate.getAggregateFunctions()) {
if (aggregateFunction.forceSkipRegulator(AggregatePhase.ONE)) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java
index ed7bd33fbd6..933da2f32d6 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java
@@ -100,7 +100,6 @@ import
org.apache.doris.nereids.trees.plans.logical.LogicalUnion;
import org.apache.doris.nereids.trees.plans.logical.LogicalWindow;
import org.apache.doris.nereids.trees.plans.logical.LogicalWorkTableReference;
import org.apache.doris.nereids.trees.plans.physical.PhysicalAssertNumRows;
-import
org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEAnchor;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEConsumer;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEProducer;
@@ -999,12 +998,6 @@ public class StatsCalculator extends
DefaultPlanVisitor<Statistics, Void> {
return computeAggregate(agg, groupExpression.childStatistics(0));
}
- @Override
- public Statistics visitPhysicalBucketedHashAggregate(
- PhysicalBucketedHashAggregate<? extends Plan> agg, Void context) {
- return computeAggregate(agg, groupExpression.childStatistics(0));
- }
-
@Override
public Statistics visitPhysicalRepeat(PhysicalRepeat<? extends Plan>
repeat, Void context) {
return computeRepeat(repeat, groupExpression.childStatistics(0));
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java
index c73396d1271..27137ca88cd 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java
@@ -132,7 +132,6 @@ public enum PlanType {
// physical others
PHYSICAL_HASH_AGGREGATE,
- PHYSICAL_BUCKETED_HASH_AGGREGATE,
PHYSICAL_ASSERT_NUM_ROWS,
PHYSICAL_CTE_PRODUCER,
PHYSICAL_CTE_ANCHOR,
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalBucketedHashAggregate.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalBucketedHashAggregate.java
deleted file mode 100644
index 09565bdc820..00000000000
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalBucketedHashAggregate.java
+++ /dev/null
@@ -1,293 +0,0 @@
-// 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.trees.plans.physical;
-
-import org.apache.doris.nereids.memo.GroupExpression;
-import org.apache.doris.nereids.properties.DataTrait;
-import org.apache.doris.nereids.properties.LogicalProperties;
-import org.apache.doris.nereids.properties.PhysicalProperties;
-import org.apache.doris.nereids.trees.expressions.Expression;
-import org.apache.doris.nereids.trees.expressions.NamedExpression;
-import org.apache.doris.nereids.trees.expressions.Slot;
-import org.apache.doris.nereids.trees.expressions.functions.agg.Count;
-import org.apache.doris.nereids.trees.expressions.functions.agg.Ndv;
-import org.apache.doris.nereids.trees.plans.Plan;
-import org.apache.doris.nereids.trees.plans.PlanType;
-import org.apache.doris.nereids.trees.plans.algebra.Aggregate;
-import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
-import org.apache.doris.nereids.util.ExpressionUtils;
-import org.apache.doris.nereids.util.Utils;
-import org.apache.doris.statistics.Statistics;
-
-import com.google.common.base.Preconditions;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-
-/**
- * Physical bucketed hash aggregation plan node.
- *
- * Fuses two-phase aggregation (local + global) into a single operator for
single-BE deployments.
- * The sink side builds per-instance hash tables from raw input (first-phase
agg).
- * The source side merges across instances per-bucket using direct in-memory
merge
- * (no serialization/deserialization) and outputs the final result.
- *
- * This node replaces the pattern: GlobalAgg -> PhysicalDistribute -> LocalAgg
- * with a single fused operator, eliminating exchange overhead entirely.
- */
-public class PhysicalBucketedHashAggregate<CHILD_TYPE extends Plan> extends
PhysicalUnary<CHILD_TYPE>
- implements Aggregate<CHILD_TYPE> {
-
- private final List<Expression> groupByExpressions;
- private final List<NamedExpression> outputExpressions;
-
- public PhysicalBucketedHashAggregate(List<Expression> groupByExpressions,
- List<NamedExpression> outputExpressions,
- LogicalProperties logicalProperties, CHILD_TYPE child) {
- this(groupByExpressions, outputExpressions, Optional.empty(),
logicalProperties, child);
- }
-
- public PhysicalBucketedHashAggregate(List<Expression> groupByExpressions,
- List<NamedExpression> outputExpressions,
- Optional<GroupExpression> groupExpression,
- LogicalProperties logicalProperties, CHILD_TYPE child) {
- super(PlanType.PHYSICAL_BUCKETED_HASH_AGGREGATE, groupExpression,
logicalProperties, child);
- this.groupByExpressions = ImmutableList.copyOf(
- Objects.requireNonNull(groupByExpressions, "groupByExpressions
cannot be null"));
- this.outputExpressions = ImmutableList.copyOf(
- Objects.requireNonNull(outputExpressions, "outputExpressions
cannot be null"));
- }
-
- /**
- * Constructor with group expression, logical properties, physical
properties, and statistics.
- */
- public PhysicalBucketedHashAggregate(List<Expression> groupByExpressions,
- List<NamedExpression> outputExpressions,
- Optional<GroupExpression> groupExpression,
- LogicalProperties logicalProperties,
- PhysicalProperties physicalProperties, Statistics statistics,
CHILD_TYPE child) {
- super(PlanType.PHYSICAL_BUCKETED_HASH_AGGREGATE, groupExpression,
logicalProperties,
- physicalProperties, statistics, child);
- this.groupByExpressions = ImmutableList.copyOf(
- Objects.requireNonNull(groupByExpressions, "groupByExpressions
cannot be null"));
- this.outputExpressions = ImmutableList.copyOf(
- Objects.requireNonNull(outputExpressions, "outputExpressions
cannot be null"));
- }
-
- @Override
- public List<Expression> getGroupByExpressions() {
- return groupByExpressions;
- }
-
- @Override
- public List<NamedExpression> getOutputExpressions() {
- return outputExpressions;
- }
-
- @Override
- public List<NamedExpression> getOutputs() {
- return outputExpressions;
- }
-
- @Override
- public <R, C> R accept(PlanVisitor<R, C> visitor, C context) {
- return visitor.visitPhysicalBucketedHashAggregate(this, context);
- }
-
- @Override
- public List<? extends Expression> getExpressions() {
- return new ImmutableList.Builder<Expression>()
- .addAll(groupByExpressions)
- .addAll(outputExpressions)
- .build();
- }
-
- @Override
- public String toString() {
- return Utils.toSqlString("PhysicalBucketedHashAggregate[" + id.asInt()
+ "]" + getGroupIdWithPrefix(),
- "stats", statistics,
- "groupByExpr", groupByExpressions,
- "outputExpr", outputExpressions
- );
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) {
- return true;
- }
- if (o == null || getClass() != o.getClass()) {
- return false;
- }
- PhysicalBucketedHashAggregate<?> that =
(PhysicalBucketedHashAggregate<?>) o;
- return Objects.equals(groupByExpressions, that.groupByExpressions)
- && Objects.equals(outputExpressions, that.outputExpressions);
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(groupByExpressions, outputExpressions);
- }
-
- @Override
- public PhysicalBucketedHashAggregate<Plan> withChildren(List<Plan>
children) {
- Preconditions.checkArgument(children.size() == 1);
- return new PhysicalBucketedHashAggregate<>(groupByExpressions,
outputExpressions,
- groupExpression, getLogicalProperties(),
- physicalProperties, statistics, children.get(0));
- }
-
- @Override
- public PhysicalBucketedHashAggregate<CHILD_TYPE> withGroupExpression(
- Optional<GroupExpression> groupExpression) {
- return new PhysicalBucketedHashAggregate<>(groupByExpressions,
outputExpressions,
- groupExpression, getLogicalProperties(), child());
- }
-
- @Override
- public Plan withGroupExprLogicalPropChildren(Optional<GroupExpression>
groupExpression,
- Optional<LogicalProperties> logicalProperties, List<Plan>
children) {
- Preconditions.checkArgument(children.size() == 1);
- return new PhysicalBucketedHashAggregate<>(groupByExpressions,
outputExpressions,
- groupExpression, logicalProperties.get(), children.get(0));
- }
-
- @Override
- public PhysicalBucketedHashAggregate<CHILD_TYPE>
withPhysicalPropertiesAndStats(
- PhysicalProperties physicalProperties, Statistics statistics) {
- return new PhysicalBucketedHashAggregate<>(groupByExpressions,
outputExpressions,
- groupExpression, getLogicalProperties(),
- physicalProperties, statistics, child());
- }
-
- @Override
- public PhysicalBucketedHashAggregate<CHILD_TYPE>
withAggOutput(List<NamedExpression> newOutput) {
- return new PhysicalBucketedHashAggregate<>(groupByExpressions,
newOutput,
- Optional.empty(), getLogicalProperties(),
- physicalProperties, statistics, child());
- }
-
- @Override
- public String shapeInfo() {
- return "bucketedHashAgg";
- }
-
- @Override
- public List<Slot> computeOutput() {
- return outputExpressions.stream()
- .map(NamedExpression::toSlot)
- .collect(ImmutableList.toImmutableList());
- }
-
- @Override
- public PhysicalBucketedHashAggregate<CHILD_TYPE> resetLogicalProperties() {
- return new PhysicalBucketedHashAggregate<>(groupByExpressions,
outputExpressions,
- groupExpression, null,
- physicalProperties, statistics, child());
- }
-
- @Override
- public void computeUnique(DataTrait.Builder builder) {
- DataTrait childFd = child(0).getLogicalProperties().getTrait();
-
- if
(groupByExpressions.stream().anyMatch(Expression::containsVolatileExpression)) {
- return;
- }
-
- ImmutableSet.Builder<Slot> groupByKeysBuilder = ImmutableSet.builder();
- for (Expression expr : groupByExpressions) {
- groupByKeysBuilder.addAll(expr.getInputSlots());
- }
- ImmutableSet<Slot> groupByKeys = groupByKeysBuilder.build();
-
- if (groupByExpressions.isEmpty() ||
childFd.isUniformAndNotNull(groupByKeys)) {
- getOutput().forEach(builder::addUniqueSlot);
- return;
- }
-
- builder.addUniqueSlot(childFd);
- builder.addUniqueSlot(groupByKeys);
-
- if (childFd.isUniqueAndNotNull(groupByKeys)) {
- for (NamedExpression namedExpression : getOutputExpressions()) {
- if (isUniqueGroupByUnique(namedExpression)) {
- builder.addUniqueSlot(namedExpression.toSlot());
- }
- }
- }
- }
-
- @Override
- public void computeUniform(DataTrait.Builder builder) {
- DataTrait childFd = child(0).getLogicalProperties().getTrait();
- builder.addUniformSlot(childFd);
-
- if
(groupByExpressions.stream().anyMatch(Expression::containsVolatileExpression)) {
- return;
- }
-
- ImmutableSet.Builder<Slot> groupByKeysBuilder = ImmutableSet.builder();
- for (Expression expr : groupByExpressions) {
- groupByKeysBuilder.addAll(expr.getInputSlots());
- }
- ImmutableSet<Slot> groupByKeys = groupByKeysBuilder.build();
-
- if (groupByExpressions.isEmpty() ||
childFd.isUniformAndNotNull(groupByKeys)) {
- getOutput().forEach(builder::addUniformSlot);
- return;
- }
-
- if (childFd.isUniqueAndNotNull(groupByKeys)) {
- for (NamedExpression namedExpression : getOutputExpressions()) {
- if (isUniformGroupByUnique(namedExpression)) {
- builder.addUniformSlot(namedExpression.toSlot());
- }
- }
- }
- }
-
- private boolean isUniqueGroupByUnique(NamedExpression namedExpression) {
- if (namedExpression.children().size() != 1) {
- return false;
- }
- Expression agg = namedExpression.child(0);
- return ExpressionUtils.isInjectiveAgg(agg)
- &&
child().getLogicalProperties().getTrait().isUniqueAndNotNull(agg.getInputSlots());
- }
-
- private boolean isUniformGroupByUnique(NamedExpression namedExpression) {
- if (namedExpression.children().size() != 1) {
- return false;
- }
- Expression agg = namedExpression.child(0);
- return agg instanceof Count || agg instanceof Ndv;
- }
-
- @Override
- public void computeEqualSet(DataTrait.Builder builder) {
- builder.addEqualSet(child().getLogicalProperties().getTrait());
- }
-
- @Override
- public void computeFd(DataTrait.Builder builder) {
- builder.addFuncDepsDG(child().getLogicalProperties().getTrait());
- }
-}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/PlanVisitor.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/PlanVisitor.java
index a68393a7f74..55b677923c5 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/PlanVisitor.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/PlanVisitor.java
@@ -62,7 +62,6 @@ import
org.apache.doris.nereids.trees.plans.logical.LogicalWindow;
import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalJoin;
import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalSort;
import org.apache.doris.nereids.trees.plans.physical.PhysicalAssertNumRows;
-import
org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEAnchor;
import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEProducer;
import org.apache.doris.nereids.trees.plans.physical.PhysicalDistribute;
@@ -327,11 +326,6 @@ public abstract class PlanVisitor<R, C> implements
CommandVisitor<R, C>, Relatio
return visit(agg, context);
}
- public R visitPhysicalBucketedHashAggregate(
- PhysicalBucketedHashAggregate<? extends Plan> agg, C context) {
- return visit(agg, context);
- }
-
public R visitPhysicalStorageLayerAggregate(PhysicalStorageLayerAggregate
storageLayerAggregate, C context) {
return storageLayerAggregate.getRelation().accept(this, context);
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/util/AggregateUtils.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/util/AggregateUtils.java
index d26818864b9..d68529fcfeb 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/util/AggregateUtils.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/util/AggregateUtils.java
@@ -35,6 +35,8 @@ import
org.apache.doris.nereids.trees.plans.logical.LogicalAggregate;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.statistics.ColumnStatistic;
import org.apache.doris.statistics.Statistics;
+import org.apache.doris.system.Backend;
+import org.apache.doris.system.SystemInfoService;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
@@ -222,4 +224,47 @@ public class AggregateUtils {
}
return true;
}
+
+ /**
+ * Check the basic environmental conditions for bucketed hash aggregation.
+ * This is the shared eligibility gate used by ChildrenPropertiesRegulator
+ * (to allow the one-phase-GLOBAL+distribute pattern), CostModel (for cost
+ * discount), and PhysicalPlanTranslator (for fusion into
BucketedAggregationNode).
+ *
+ * @return true if the session variable is enabled, there is exactly one
alive BE,
+ * no smooth upgrade is in progress, and the aggregate has GROUP
BY keys.
+ */
+ public static boolean isBucketedHashAggEnabled(int groupByExprCount) {
+ ConnectContext ctx = ConnectContext.get();
+ if (ctx == null) {
+ return false;
+ }
+ if (!ctx.getSessionVariable().enableBucketedHashAgg) {
+ return false;
+ }
+ // Must have GROUP BY keys (without-key aggregation not supported)
+ if (groupByExprCount == 0) {
+ return false;
+ }
+ // Correctness gate: single-BE only (cross-BE in-memory merge is
impossible).
+ // Use be_number_for_test first (set by regression tests), fall back
to real cluster count.
+ // Note: do not clamp to 1 — with zero backends bucketed agg must not
be enabled.
+ int beNumber = ctx.getSessionVariable().getBeNumberForTest();
+ if (beNumber <= 0) {
+ beNumber = ctx.getEnv().getClusterInfo().getBackendsNumber(true);
+ }
+ if (beNumber != 1) {
+ return false;
+ }
+ // Smooth upgrade safety net: old BE processes do not recognize
+ // BUCKETED_AGGREGATION_NODE plan node type
+ SystemInfoService clusterInfo = ctx.getEnv().getClusterInfo();
+ for (Long beId : clusterInfo.getAllBackendByCurrentCluster(true)) {
+ Backend be = clusterInfo.getBackend(beId);
+ if (be != null && be.isSmoothUpgradeSrc()) {
+ return false;
+ }
+ }
+ return true;
+ }
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/properties/RequestPropertyDeriverTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/properties/RequestPropertyDeriverTest.java
index 36322ed9b99..bffe00a0496 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/properties/RequestPropertyDeriverTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/properties/RequestPropertyDeriverTest.java
@@ -49,7 +49,6 @@ import org.apache.doris.nereids.trees.plans.JoinType;
import org.apache.doris.nereids.trees.plans.RelationId;
import org.apache.doris.nereids.trees.plans.logical.LogicalOneRowRelation;
import org.apache.doris.nereids.trees.plans.physical.PhysicalAssertNumRows;
-import
org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate;
import
org.apache.doris.nereids.trees.plans.physical.PhysicalExternalRowLevelMergeSink;
import org.apache.doris.nereids.trees.plans.physical.PhysicalHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalHashJoin;
@@ -493,23 +492,4 @@ class RequestPropertyDeriverTest {
Lists.newArrayList(key1.getExprId(), key2.getExprId()),
ShuffleType.REQUIRE);
Assertions.assertTrue(actual.contains(ImmutableList.of(aggProp)) &&
actual.contains(ImmutableList.of(parentProp)));
}
-
- @Test
- void testBucketedHashAggregate() {
- SlotReference key = new SlotReference("col1", IntegerType.INSTANCE);
- PhysicalBucketedHashAggregate<GroupPlan> aggregate = new
PhysicalBucketedHashAggregate<>(
- Lists.newArrayList(key),
- Lists.newArrayList(key),
- logicalProperties,
- groupPlan
- );
- GroupExpression groupExpression = new GroupExpression(aggregate);
- new Group(null, groupExpression, null);
- RequestPropertyDeriver requestPropertyDeriver = new
RequestPropertyDeriver(null, jobContext);
- List<List<PhysicalProperties>> actual
- =
requestPropertyDeriver.getRequestChildrenPropertyList(groupExpression);
- List<List<PhysicalProperties>> expected = Lists.newArrayList();
- expected.add(Lists.newArrayList(PhysicalProperties.ANY));
- Assertions.assertEquals(expected, actual);
- }
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/implementation/BucketedAggregateTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/implementation/BucketedAggregateTest.java
index 91b0fc4688e..ccef8b42abf 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/implementation/BucketedAggregateTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/implementation/BucketedAggregateTest.java
@@ -25,6 +25,7 @@ import
org.apache.doris.nereids.trees.expressions.NamedExpression;
import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator;
import org.apache.doris.nereids.trees.expressions.functions.agg.Sum;
+import org.apache.doris.nereids.trees.plans.AggMode;
import org.apache.doris.nereids.trees.plans.AggPhase;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate;
@@ -44,6 +45,13 @@ import org.junit.jupiter.api.TestInstance;
import java.util.List;
import java.util.Optional;
+/**
+ * Tests for bucketed hash aggregation. Since the bucketed fusion now happens
+ * at the translator level (PhysicalPlanTranslator fuses one-phase GLOBAL
+ * PhysicalHashAggregate + PhysicalDistribute into BucketedAggregationNode),
+ * these tests verify that the optimizer generates the correct one-phase
+ * candidates that the translator will later fuse.
+ */
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class BucketedAggregateTest implements MemoPatternMatchSupported {
private Plan rStudent;
@@ -84,42 +92,69 @@ public class BucketedAggregateTest implements
MemoPatternMatchSupported {
@Test
public void testBucketedAggDisabled() {
+ // When bucketed is disabled, the one-phase GLOBAL(INPUT_TO_RESULT)
candidate is
+ // still generated by the rule (the translator just won't fuse it).
+ // Note: PhysicalDistribute is inserted by the enforcer during full
optimization,
+ // not by the implementation rule itself. This test verifies
rule-level output.
Plan root = buildAggregateWithGroupBy();
ConnectContext ctx = MemoTestUtils.createConnectContext();
ctx.getSessionVariable().enableBucketedHashAgg = false;
ctx.getSessionVariable().setBeNumberForTest(1);
PlanChecker.from(ctx, root)
+ .deriveStats()
.applyImplementation(splitAggWithoutDistinctRule())
- .nonMatch(physicalBucketedHashAggregate());
+ // one-phase GLOBAL INPUT_TO_RESULT candidate exists
+ .matches(physicalHashAggregate()
+ .when(agg -> agg.getAggPhase().equals(AggPhase.GLOBAL)
+ &&
agg.getAggMode().equals(AggMode.INPUT_TO_RESULT)));
}
@Test
public void testBucketedAggMultiBE() {
+ // On multi-BE, both one-phase and two-phase are generated at rule
level.
+ // The cost model will prefer two-phase (no bucketed discount for
beNumber>1).
Plan root = buildAggregateWithGroupBy();
ConnectContext ctx = MemoTestUtils.createConnectContext();
ctx.getSessionVariable().enableBucketedHashAgg = true;
ctx.getSessionVariable().setBeNumberForTest(3);
PlanChecker.from(ctx, root)
+ .deriveStats()
.applyImplementation(splitAggWithoutDistinctRule())
- .nonMatch(physicalBucketedHashAggregate());
+ // one-phase GLOBAL INPUT_TO_RESULT candidate exists
+ .matches(physicalHashAggregate()
+ .when(agg -> agg.getAggPhase().equals(AggPhase.GLOBAL)
+ &&
agg.getAggMode().equals(AggMode.INPUT_TO_RESULT)))
+ // two-phase also exists
+ .matches(physicalHashAggregate(physicalHashAggregate()
+ .when(child ->
child.getAggPhase().equals(AggPhase.LOCAL))));
}
@Test
public void testBucketedAggNoGroupBy() {
+ // Without GROUP BY, scalar aggregation is generated (GATHER, no
distribute).
+ // Bucketed fusion only applies to grouped aggregation.
Plan root = buildAggregateWithoutGroupBy();
ConnectContext ctx = MemoTestUtils.createConnectContext();
ctx.getSessionVariable().enableBucketedHashAgg = true;
ctx.getSessionVariable().setBeNumberForTest(1);
PlanChecker.from(ctx, root)
+ .deriveStats()
.applyImplementation(splitAggWithoutDistinctRule())
- .nonMatch(physicalBucketedHashAggregate());
+ // scalar agg is GLOBAL GATHER, no distribute child
+ .matches(physicalHashAggregate()
+ .when(agg -> agg.getAggPhase().equals(AggPhase.GLOBAL)
+ && agg.getGroupByExpressions().isEmpty()));
}
@Test
public void testBucketedAggEnabled() {
+ // With all bucketed conditions met, verify the one-phase
GLOBAL(INPUT_TO_RESULT)
+ // candidate is generated. The enforcer will later insert
PhysicalDistribute,
+ // and the translator will fuse them into BucketedAggregationNode.
+ // Key: distinguish from two-phase merge which is
GLOBAL(BUFFER_TO_RESULT).
Plan root = buildAggregateWithGroupBy();
ConnectContext ctx = MemoTestUtils.createConnectContext();
ctx.getSessionVariable().enableBucketedHashAgg = true;
@@ -132,11 +167,19 @@ public class BucketedAggregateTest implements
MemoPatternMatchSupported {
PlanChecker.from(ctx, root)
.deriveStats()
.applyImplementation(splitAggWithoutDistinctRule())
- .matches(physicalBucketedHashAggregate());
+ // one-phase GLOBAL(INPUT_TO_RESULT) exists
+ .matches(physicalHashAggregate()
+ .when(agg -> agg.getAggPhase().equals(AggPhase.GLOBAL)
+ &&
agg.getAggMode().equals(AggMode.INPUT_TO_RESULT)))
+ // two-phase (LOCAL -> GLOBAL) also exists
+ .matches(physicalHashAggregate(physicalHashAggregate()
+ .when(child ->
child.getAggPhase().equals(AggPhase.LOCAL))));
}
@Test
public void testSingleExecutionInstanceOnlyGeneratesOnePhaseAgg() {
+ // When parallelPipelineTaskNum=1, only one-phase is generated
+ // (no two-phase because there's nowhere to distribute to).
Plan root = buildAggregateWithGroupBy();
ConnectContext ctx = MemoTestUtils.createConnectContext();
ctx.getSessionVariable().enableBucketedHashAgg = true;
@@ -149,16 +192,18 @@ public class BucketedAggregateTest implements
MemoPatternMatchSupported {
PlanChecker.from(ctx, root)
.deriveStats()
.applyImplementation(splitAggWithoutDistinctRule())
+ // one-phase GLOBAL exists
.matches(physicalHashAggregate()
.when(agg ->
agg.getAggPhase().equals(AggPhase.GLOBAL)))
- .nonMatch(physicalHashAggregate(physicalHashAggregate()))
- .nonMatch(physicalBucketedHashAggregate());
+ // two-phase (nested agg) must NOT exist
+ .nonMatch(physicalHashAggregate(physicalHashAggregate()));
}
@Test
public void testBucketedAggForcedAggPhase() {
- // When user forces agg_phase = 2, bucketed agg should NOT be generated
- // because it is only added in auto mode (aggPhase == 0).
+ // When user forces agg_phase = 2, only two-phase is generated.
+ // The one-phase GLOBAL(INPUT_TO_RESULT) candidate is not generated,
+ // so there's no pattern for the translator to fuse.
Plan root = buildAggregateWithGroupBy();
ConnectContext ctx = MemoTestUtils.createConnectContext();
ctx.getSessionVariable().enableBucketedHashAgg = true;
@@ -171,6 +216,12 @@ public class BucketedAggregateTest implements
MemoPatternMatchSupported {
PlanChecker.from(ctx, root)
.deriveStats()
.applyImplementation(splitAggWithoutDistinctRule())
- .nonMatch(physicalBucketedHashAggregate());
+ // two-phase pattern must exist
+ .matches(physicalHashAggregate(physicalHashAggregate()
+ .when(child ->
child.getAggPhase().equals(AggPhase.LOCAL))))
+ // one-phase GLOBAL INPUT_TO_RESULT must NOT exist
+ .nonMatch(physicalHashAggregate()
+ .when(agg -> agg.getAggPhase().equals(AggPhase.GLOBAL)
+ &&
agg.getAggMode().equals(AggMode.INPUT_TO_RESULT)));
}
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/statistics/query/QueryStatsRecorderTest.java
b/fe/fe-core/src/test/java/org/apache/doris/statistics/query/QueryStatsRecorderTest.java
index 2dbf6da6dd2..81b2aef710c 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/statistics/query/QueryStatsRecorderTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/statistics/query/QueryStatsRecorderTest.java
@@ -820,34 +820,6 @@ public class QueryStatsRecorderTest {
Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit, "k1:
window ORDER BY key");
}
- /**
- * PhysicalBucketedHashAggregate implements Aggregate but does not extend
PhysicalHashAggregate.
- * The Aggregate interface check must cover it.
- */
- @Test
- @SuppressWarnings("unchecked")
- public void testBucketedAggregateGroupByQueryHit() {
- ExprId id1 = new ExprId(1);
- SlotReference k1Slot = mockSlot(id1, "k1");
- PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot));
-
- Expression groupExpr = Mockito.mock(Expression.class);
-
Mockito.when(groupExpr.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot));
-
-
org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate<?>
agg =
-
Mockito.mock(org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate.class);
- Mockito.when(agg.children()).thenReturn(ImmutableList.of(scan));
-
Mockito.when(agg.getGroupByExpressions()).thenReturn(ImmutableList.of(groupExpr));
-
Mockito.when(agg.getOutputExpressions()).thenReturn(ImmutableList.of());
- Mockito.when(agg.getOutput()).thenReturn(ImmutableList.of(k1Slot));
-
- Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) agg);
-
- StatsDelta delta = deltas.get("1_1_1_1");
- Assertions.assertNotNull(delta, "bucketed aggregate must be recorded
via Aggregate interface");
- Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit, "k1:
GROUP BY in bucketed agg");
- }
-
/**
* JOIN ON t1.k1 = t2.k2: both hash-join and other-join conjuncts →
filterHit.
*/
diff --git
a/regression-test/data/nereids_rules_p0/agg_strategy/bucketed_hash_agg.out
b/regression-test/data/nereids_rules_p0/agg_strategy/bucketed_hash_agg.out
new file mode 100644
index 00000000000..b2507b7b088
--- /dev/null
+++ b/regression-test/data/nereids_rules_p0/agg_strategy/bucketed_hash_agg.out
@@ -0,0 +1,40 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !bucketed_shape --
+PhysicalResultSink
+--PhysicalDistribute[DistributionSpecGather]
+----hashAgg[GLOBAL]
+------PhysicalDistribute[DistributionSpecHash]
+--------PhysicalProject
+----------PhysicalOlapScan[bucketed_agg_reg_test]
+
+-- !bucketed_result --
+a 90
+b 240
+c 220
+
+-- !multi_be_shape --
+PhysicalResultSink
+--PhysicalDistribute[DistributionSpecGather]
+----hashAgg[GLOBAL]
+------PhysicalDistribute[DistributionSpecHash]
+--------hashAgg[LOCAL]
+----------PhysicalProject
+------------PhysicalOlapScan[bucketed_agg_reg_test]
+
+-- !multi_be_result --
+a 90
+b 240
+c 220
+
+-- !disabled_result --
+a 90
+b 240
+c 220
+
+-- !no_group_by_result --
+550
+
+-- !count_distinct_result --
+a 4 200
+b 5 370
+c 4 340
diff --git
a/regression-test/suites/nereids_rules_p0/agg_strategy/bucketed_hash_agg.groovy
b/regression-test/suites/nereids_rules_p0/agg_strategy/bucketed_hash_agg.groovy
new file mode 100644
index 00000000000..fda888d84d5
--- /dev/null
+++
b/regression-test/suites/nereids_rules_p0/agg_strategy/bucketed_hash_agg.groovy
@@ -0,0 +1,162 @@
+// 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.
+
+suite("bucketed_hash_agg") {
+ // ============================================================
+ // Test: Bucketed Hash Aggregation regression
+ //
+ // Verifies that on single-BE deployments with
enable_bucketed_hash_agg=true,
+ // the translator fuses one-phase GLOBAL hash aggregate + distribute into
+ // a single BUCKETED AGGREGATE operator, eliminating exchange overhead.
+ // On multi-BE deployments, bucketed agg must NOT be used.
+ // ============================================================
+
+ // --- session settings ---
+ sql "set enable_nereids_planner=true"
+ sql "set enable_parallel_result_sink=false"
+ sql "set runtime_filter_mode=OFF"
+ sql "set parallel_pipeline_task_num=2"
+ sql "set bucketed_agg_min_input_rows=0"
+ sql "set bucketed_agg_max_group_keys=0"
+ // The table below is never analyzed, so group-by column stats are unknown
and
+ // StatsCalculator falls back to rows * DEFAULT_AGGREGATE_RATIO (1/3.0)
for the
+ // aggregate output cardinality. With the default
bucketed_agg_high_card_threshold
+ // (0.3), bucketedDataVolumeGatesPass rejects the pattern (rows/3 >
rows*0.3),
+ // so raise the threshold to make the positive fusion test deterministic.
+ sql "set bucketed_agg_high_card_threshold=1.0"
+
+ // --- create test table ---
+ sql """ DROP TABLE IF EXISTS bucketed_agg_reg_test; """
+ sql """
+ CREATE TABLE bucketed_agg_reg_test (
+ id int,
+ grp varchar(20),
+ val bigint
+ ) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 3
+ PROPERTIES('replication_num' = '1');
+ """
+ sql """ INSERT INTO bucketed_agg_reg_test VALUES
+ (1, 'a', 10),
+ (2, 'b', 20),
+ (3, 'a', 30),
+ (4, 'b', 40),
+ (5, 'a', 50),
+ (1, 'c', 60),
+ (2, 'c', 70),
+ (3, 'b', 80),
+ (4, 'c', 90),
+ (5, 'b', 100);
+ """
+
+ // ============================================================
+ // Test 1: Positive — single-BE, bucketed enabled
+ // EXPLAIN should contain BUCKETED AGGREGATE
+ // ============================================================
+ sql "set be_number_for_test=1"
+ sql "set enable_bucketed_hash_agg = true;"
+
+ String query = "SELECT grp, SUM(val) FROM bucketed_agg_reg_test GROUP BY
grp;"
+ explain {
+ sql("${query}")
+ contains("BUCKETED AGGREGATE")
+ }
+
+ // Shape plan should show one-phase: hashAgg[GLOBAL] → shuffle → scan (no
LOCAL)
+ qt_bucketed_shape """explain shape plan
+ ${query}
+ """
+
+ // Verify correct results
+ order_qt_bucketed_result """
+ SELECT grp, SUM(val) FROM bucketed_agg_reg_test GROUP BY grp ORDER BY grp;
+ """
+
+ // ============================================================
+ // Test 2: Negative — be_number=3 (multi-BE), bucketed enabled
+ // Must NOT use bucketed agg, must fall back to two-phase
+ // ============================================================
+ sql "set be_number_for_test=3"
+ sql "set enable_bucketed_hash_agg = true;"
+
+ explain {
+ sql("${query}")
+ notContains("BUCKETED AGGREGATE")
+ }
+
+ // Shape plan should show two-phase: hashAgg[GLOBAL] → shuffle →
hashAgg[LOCAL] → scan
+ qt_multi_be_shape """explain shape plan
+ ${query}
+ """
+
+ // Results must match the single-BE bucketed result
+ order_qt_multi_be_result """
+ SELECT grp, SUM(val) FROM bucketed_agg_reg_test GROUP BY grp ORDER BY grp;
+ """
+
+ // ============================================================
+ // Test 3: Negative — bucketed disabled
+ // Must fall back to two-phase
+ // ============================================================
+ sql "set be_number_for_test=1"
+ sql "set enable_bucketed_hash_agg = false;"
+
+ explain {
+ sql("${query}")
+ notContains("BUCKETED AGGREGATE")
+ }
+
+ order_qt_disabled_result """
+ SELECT grp, SUM(val) FROM bucketed_agg_reg_test GROUP BY grp ORDER BY grp;
+ """
+
+ // ============================================================
+ // Test 4: Negative — scalar aggregation (no GROUP BY)
+ // Bucketed agg does not apply
+ // ============================================================
+ sql "set be_number_for_test=1"
+ sql "set enable_bucketed_hash_agg = true;"
+
+ String scalarQuery = "SELECT SUM(val) FROM bucketed_agg_reg_test;"
+ explain {
+ sql("${scalarQuery}")
+ notContains("BUCKETED AGGREGATE")
+ }
+
+ order_qt_no_group_by_result """
+ SELECT SUM(val) FROM bucketed_agg_reg_test;
+ """
+
+ // ============================================================
+ // Test 5: COUNT(DISTINCT) + GROUP BY — results must be correct
+ // ============================================================
+ sql "set be_number_for_test=1"
+ sql "set enable_bucketed_hash_agg = true;"
+ sql """
+ INSERT INTO bucketed_agg_reg_test VALUES
+ (6, 'a', 110),
+ (7, 'c', 120),
+ (8, 'b', 130);
+ """
+
+ order_qt_count_distinct_result """
+ SELECT grp, COUNT(DISTINCT id), SUM(val)
+ FROM bucketed_agg_reg_test
+ GROUP BY grp
+ ORDER BY grp;
+ """
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]