Skip to content

[opt](aggregate) route multi-distinct group-by to CTE split to avoid OOM#66045

Open
Baymine wants to merge 3 commits into
apache:masterfrom
Baymine:improvement/multi-distinct-cte-oom
Open

[opt](aggregate) route multi-distinct group-by to CTE split to avoid OOM#66045
Baymine wants to merge 3 commits into
apache:masterfrom
Baymine:improvement/multi-distinct-cte-oom

Conversation

@Baymine

@Baymine Baymine commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: close #66044

Problem Summary:
The MultiDistinct strategy keeps a per-group hash set of every distinct value on a single node.
For a group-by query whose distinct argument is near-unique (or has unknown statistics that could
be near-unique), one node can hold a whole group's value set and OOM. The no-group-by branch of
DistinctAggStrategySelector.shouldUseMultiDistinct already guards against this; the group-by
branch did not.

This PR adds two coordinated guards to the group-by branch of shouldUseMultiDistinct, matching
the no-group-by branch's behavior (two commits, logically distinct):

  1. hasHighNdvDistinctArgument: force CTE split when any distinct argument's estimated NDV >=
    row * MID_CARDINALITY_THRESHOLD. Estimates via ExpressionEstimation so if(cond, col, null)
    propagates col's NDV. Placed before hasUnknownStatistics(groupBy) so a function group-by key
    with unknown stats is still protected. Unknown stats do not trigger this guard (avoids pushing
    safe cases onto the costlier CTE path).

  2. hasUnknownNdvDistinctArgument: force CTE split when a distinct argument's estimate is unknown
    OR any input slot it reads has absent/unknown statistics. The per-slot scan is required because
    ExpressionEstimation.visitIf fabricates a small non-unknown NDV for if(cond, col, null) even
    when col is unanalyzed, which would otherwise let a near-unique wrapped column escape guard.

  3. When group-by stats are unknown (now reached only when every distinct argument is confirmed
    low-NDV), prefer CTE split for a single group-by key (parallelism is capped at the unknown group
    count) and keep MultiDistinct for >= 2 keys (joint cardinality is usually high enough to spread
    safely).

Release note

MultiDistinct is no longer chosen for group-by queries whose distinct argument is near-unique
relative to input row count, whose distinct argument has unknown statistics (including arguments
wrapped in expressions such as if(...) whose underlying input slots are unanalyzed), or whose
single group-by key has unknown statistics; such queries route to the CTE split strategy to avoid
potential OOM.

Check List (For Author)

  • Test: Unit Test + Regression test
    • FE unit test SplitMultiDistinctTest#multiSumWithGby: updated to assert the CTE split shape.
    • FE unit test AggregateUtilsHighNdvDistinctTest (new, 11 cases): covers both helpers —
      high-NDV triggers / low-NDV doesn't / unknown doesn't trigger the high-NDV guard / if-wrapped
      near-unique triggers / threshold boundary; and for the unknown guard: all-unknown triggers /
      one-unknown-among-known triggers / all-known doesn't / if-wrapped-known doesn't /
      if-wrapped-unknown triggers (the per-slot-scan gap) / year()-over-unknown-slot triggers.
    • Regression test distinct_agg_strategy_selector: the pre-existing WITH-stats case
      should_use_multi_distinct_with_group_by (query count(distinct d_20), count(distinct b_5) ... group by a_1, where d_20 is near-unique) now correctly routes to CTE split, so it was
      renamed to should_use_cte_with_group_by_high_ndv with an explanatory comment; and the
      no-stats case was renamed no_stats_should_use_multi_distinct -> no_stats_should_use_cte_with_group_by.
      The .out was regenerated by the regression harness against a live cluster (not hand-written).
  • Behavior changed: Yes (multi-distinct strategy selection for group-by queries with high-NDV or
    unknown-statistics distinct arguments, or unknown single-key group-by stats, now prefers CTE split).
  • Does this need documentation: No

Baymine added 2 commits July 25, 2026 09:07
… to avoid OOM

### What problem does this PR solve?

Issue Number: close apache#66044

Problem Summary: MultiDistinct keeps a per-group hash set of every distinct
value on a single node. Under a group-by with a low- or unknown-cardinality
key, a near-unique distinct argument (e.g. count(distinct payment_id) group by
merchant_id) forces one node to hold a whole group's value set and can OOM.
The no-group-by branch of DistinctAggStrategySelector already guards against
this by checking distinct-argument NDV; the group-by branch did not, and on
unknown group-by stats it unconditionally returned true (chose MultiDistinct).

This change adds two guards to the group-by branch of shouldUseMultiDistinct:

1. AggregateUtils.hasHighNdvDistinctArgument(agg, childStats, row): returns true
   when any distinct argument's estimated NDV >= row * MID_CARDINALITY_THRESHOLD,
   forcing CTE split (which redistributes on the distinct key). It estimates via
   ExpressionEstimation so if(cond, col, null) propagates col's NDV through the
   then-branch. Unknown stats do not trigger, to avoid pushing safe cases onto
   the costlier CTE path. This check runs before hasUnknownStatistics so a
   function group-by key (e.g. format_datetime) with unknown stats is still
   protected.

2. When group-by stats are unknown, instead of unconditionally choosing
   MultiDistinct (return true), prefer CTE split for a single group-by key
   (parallelism would otherwise be capped at the unknown group count) and keep
   MultiDistinct only for >=2 group-by keys (joint cardinality is usually high
   enough to spread safely). Mirrors StarRocks' fallback.

End-to-end: a query of the form
  select count(distinct near_unique_col), ... from t group by low_card_key
that previously planned as a single MultiDistinct node (and OOMed on large
inputs) now plans as a CTE split with a hash join over two redistributed
distinct aggregates.

### Release note

MultiDistinct is no longer chosen for group-by queries whose distinct argument
is near-unique relative to the input row count, or whose single group-by key
has unknown statistics; such queries route to the CTE split strategy to avoid
potential OOM.

### Check List (For Author)

- Test: Unit Test / Regression test
    - FE unit test: SplitMultiDistinctTest#multiSumWithGby (updated to assert the
      new CTE split plan shape: physicalCTEAnchor/physicalCTEProducer + hashJoin).
    - FE unit test: AggregateUtilsHighNdvDistinctTest (new) covers
      AggregateUtils.hasHighNdvDistinctArgument directly: near-unique argument
      triggers, low-NDV does not, unknown stats do not trigger, if-wrapped
      near-unique argument triggers (ExpressionEstimation.visitIf propagation),
      and NDV exactly at the threshold counts as high.
- Behavior changed: Yes (multi-distinct strategy selection for group-by queries
  with high-NDV distinct arguments or unknown single-key group-by stats now
  prefers CTE split).
- Does this need documentation: No
### What problem does this PR solve?

Issue Number: close apache#66044

Problem Summary: Building on the high-NDV guard from the previous commit, this
closes a remaining OOM hole in the group-by branch of shouldUseMultiDistinct:
a distinct argument whose statistics are UNKNOWN. Because MultiDistinct keeps a
per-group hash set of every distinct value on a single node, an unknown distinct
argument could be near-unique and OOM one node under a low- or unknown-cardinality
group by. The no-group-by branch already routes unknown distinct statistics to
CTE; the group-by branch did not.

This change adds AggregateUtils.hasUnknownNdvDistinctArgument(agg, childStats),
which returns true (forcing CTE split) when a distinct argument's estimated
statistics are unknown OR any input Slot it reads has absent/unknown statistics.
It is wired into the group-by branch immediately after hasHighNdvDistinctArgument,
before hasUnknownStatistics(groupBy), so that only when every distinct argument is
confirmed low-NDV (neither high nor unknown) does execution fall through to the
group-by cardinality checks.

The per-input-slot scan is required because ExpressionEstimation fabricates a
small non-unknown NDV for wrappers such as if(cond, col, null) (visitIf) even
when col itself is unanalyzed -- without the slot scan, a near-unique column
wrapped in if(cond, payment_id, null) would escape both the high-NDV and the
unknown-NDV guards. The slot scan treats that case as risky, trading a costlier
plan for OOM safety. It is intentionally conservative: a low-NDV derived function
like year() over an unanalyzed base slot also routes to CTE.

End-to-end: a query of the form
  select count(distinct if(flag, payment_id, null)), ... from t group by merchant_id
where payment_id has no collected statistics previously planned as a single
MultiDistinct node (and could OOM) now plans as a CTE split.

### Release note

MultiDistinct is no longer chosen for group-by queries whose distinct argument
has unknown statistics (including arguments wrapped in if/other expressions whose
underlying input slots are unanalyzed); such queries route to the CTE split
strategy to avoid potential OOM.

### Check List (For Author)

- Test: Unit Test / Regression test
    - FE unit test: AggregateUtilsHighNdvDistinctTest extended with 6 cases for
      hasUnknownNdvDistinctArgument: all-unknown triggers, one-unknown-among-known
      triggers, all-known does not trigger, if-wrapped known does not trigger,
      if-wrapped unknown triggers (the per-slot-scan gap), and year() over an
      unanalyzed slot triggers (conservative fallback).
    - Regression test: distinct_agg_strategy_selector renamed
      qt_no_stats_should_use_multi_distinct to
      qt_no_stats_should_use_cte_with_group_by and flipped the expected plan to
      the CTE split shape. NOTE: the .out was ported from the reference fix's
      expected EXPLAIN SHAPE plan and must be regenerated by CI (a live cluster
      with an alive BE was not available locally to run the regression harness).
- Behavior changed: Yes (multi-distinct strategy selection for group-by queries
  with unknown-statistics distinct arguments now prefers CTE split).
- Does this need documentation: No
@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@Baymine

Baymine commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

run buildall

…istinct-cte-oom

# Conflicts:
#	fe/fe-core/src/main/java/org/apache/doris/nereids/util/AggregateUtils.java
@Baymine

Baymine commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 79.17% (19/24) 🎉
Increment coverage report
Complete coverage report

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 29535 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit 3bd8e56f1134382b38276a24148fd877a9e6d33c, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17696	4077	4025	4025
q2	2026	325	200	200
q3	10310	1422	830	830
q4	4679	471	339	339
q5	7494	873	576	576
q6	183	171	134	134
q7	771	819	617	617
q8	9340	1645	1635	1635
q9	5602	4398	4365	4365
q10	6750	1751	1481	1481
q11	509	372	330	330
q12	743	574	456	456
q13	18102	3400	2769	2769
q14	265	262	246	246
q15	q16	798	792	706	706
q17	960	967	954	954
q18	6857	5706	5574	5574
q19	1322	1192	1107	1107
q20	790	674	555	555
q21	5948	2615	2346	2346
q22	427	355	290	290
Total cold run time: 101572 ms
Total hot run time: 29535 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	4371	4268	4281	4268
q2	282	316	212	212
q3	4613	4939	4406	4406
q4	2080	2217	1355	1355
q5	4499	4543	4436	4436
q6	232	182	129	129
q7	1843	2008	1799	1799
q8	2628	2260	2237	2237
q9	8176	8225	7966	7966
q10	4666	4663	4171	4171
q11	581	407	379	379
q12	747	781	528	528
q13	3297	3658	2952	2952
q14	294	309	271	271
q15	q16	713	726	640	640
q17	1340	1313	1296	1296
q18	7862	7391	7125	7125
q19	1170	1125	1062	1062
q20	2206	2184	1923	1923
q21	5186	4535	4381	4381
q22	514	442	383	383
Total cold run time: 57300 ms
Total hot run time: 51919 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 177654 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit 3bd8e56f1134382b38276a24148fd877a9e6d33c, data reload: false

query5	4322	633	493	493
query6	469	240	213	213
query7	4863	579	352	352
query8	344	192	173	173
query9	8872	4112	4099	4099
query10	470	370	324	324
query11	5872	2349	2134	2134
query12	155	102	99	99
query13	1277	597	431	431
query14	6232	5191	4825	4825
query14_1	4208	4238	4247	4238
query15	217	203	179	179
query16	1006	509	438	438
query17	957	716	584	584
query18	2460	482	350	350
query19	214	193	151	151
query20	111	109	106	106
query21	226	158	141	141
query22	13631	13620	13340	13340
query23	17315	16545	16075	16075
query23_1	16235	16273	16220	16220
query24	7442	1738	1287	1287
query24_1	1312	1295	1307	1295
query25	599	470	410	410
query26	1423	376	215	215
query27	2517	607	378	378
query28	4482	1998	1995	1995
query29	1097	656	506	506
query30	343	266	231	231
query31	1120	1092	974	974
query32	104	66	64	64
query33	540	341	271	271
query34	1175	1147	657	657
query35	782	788	669	669
query36	1195	1217	1055	1055
query37	156	115	94	94
query38	1873	1746	1663	1663
query39	871	862	843	843
query39_1	834	832	870	832
query40	245	171	148	148
query41	75	72	70	70
query42	99	93	93	93
query43	327	325	290	290
query44	1398	777	780	777
query45	219	186	172	172
query46	1083	1210	740	740
query47	2159	2126	1974	1974
query48	407	409	303	303
query49	597	442	329	329
query50	1073	439	334	334
query51	11027	10686	10513	10513
query52	88	95	77	77
query53	267	292	203	203
query54	290	257	259	257
query55	79	76	72	72
query56	323	323	310	310
query57	1307	1277	1220	1220
query58	332	256	265	256
query59	1535	1621	1393	1393
query60	315	268	258	258
query61	173	147	153	147
query62	535	492	424	424
query63	247	204	206	204
query64	2845	1050	866	866
query65	4714	4633	4658	4633
query66	1830	512	377	377
query67	29190	29182	29060	29060
query68	3178	1576	972	972
query69	410	303	267	267
query70	1058	968	931	931
query71	374	351	320	320
query72	3078	2659	2425	2425
query73	823	772	434	434
query74	5050	4917	4744	4744
query75	2532	2489	2132	2132
query76	2342	1167	755	755
query77	345	372	289	289
query78	11832	11891	11273	11273
query79	1382	1170	761	761
query80	1306	540	468	468
query81	505	334	281	281
query82	610	169	121	121
query83	380	328	295	295
query84	339	165	129	129
query85	968	613	528	528
query86	414	298	256	256
query87	1854	1827	1812	1812
query88	3686	2777	2757	2757
query89	438	379	323	323
query90	1974	202	203	202
query91	204	189	163	163
query92	62	63	56	56
query93	1605	1598	928	928
query94	725	371	355	355
query95	788	608	474	474
query96	1124	777	343	343
query97	2674	2676	2479	2479
query98	235	208	199	199
query99	1098	1114	977	977
Total cold run time: 263871 ms
Total hot run time: 177654 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 25.12 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit 3bd8e56f1134382b38276a24148fd877a9e6d33c, data reload: false

query1	0.01	0.01	0.01
query2	0.10	0.05	0.04
query3	0.26	0.13	0.14
query4	1.61	0.14	0.14
query5	0.26	0.24	0.22
query6	1.21	1.06	1.09
query7	0.04	0.01	0.01
query8	0.05	0.03	0.04
query9	0.37	0.30	0.30
query10	0.58	0.55	0.54
query11	0.21	0.14	0.14
query12	0.18	0.14	0.15
query13	0.46	0.47	0.48
query14	1.03	1.00	1.00
query15	0.62	0.59	0.61
query16	0.31	0.32	0.32
query17	1.13	1.11	1.11
query18	0.22	0.21	0.21
query19	2.02	1.92	1.93
query20	0.02	0.01	0.01
query21	15.44	0.20	0.13
query22	4.94	0.05	0.05
query23	16.16	0.30	0.12
query24	2.97	0.42	0.35
query25	0.11	0.05	0.05
query26	0.75	0.21	0.15
query27	0.03	0.03	0.04
query28	3.57	0.90	0.53
query29	12.52	4.13	3.30
query30	0.28	0.15	0.16
query31	2.77	0.57	0.30
query32	3.22	0.59	0.48
query33	3.30	3.22	3.22
query34	15.62	4.21	3.53
query35	3.51	3.53	3.54
query36	0.56	0.44	0.43
query37	0.10	0.07	0.07
query38	0.06	0.04	0.04
query39	0.04	0.03	0.03
query40	0.19	0.17	0.15
query41	0.08	0.04	0.03
query42	0.04	0.03	0.03
query43	0.04	0.03	0.03
Total cold run time: 96.99 s
Total hot run time: 25.12 s

@morrySnow morrySnow changed the title [improvement](fe) route multi-distinct group-by to CTE split to avoid OOM [improvement](aggregate) route multi-distinct group-by to CTE split to avoid OOM Jul 27, 2026
@morrySnow morrySnow changed the title [improvement](aggregate) route multi-distinct group-by to CTE split to avoid OOM [opt](aggregate) route multi-distinct group-by to CTE split to avoid OOM Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] MultiDistinct can OOM on group-by with high-NDV or unknown-statistics distinct arguments

2 participants