Thread
-
Propagate stadistinct through GROUP BY/DISTINCT in subqueries and CTEs
Richard Guo <guofenglinux@gmail.com> — 2026-04-13T01:18:42Z
I complained in [1] that some TPC-DS queries suffer from very poor cardinality estimates on CTE scan filters, to the point that simply disabling nestloop makes some queries run hundreds of times faster. Here's a simple reproduction: create table t (a int, b int, c int); insert into t select i%2, i, i from generate_series(1,1000) i; analyze t; explain analyze with cte as (select a, b, avg(c) as avg from t group by a, b) select * from cte t1, cte t2 where t1.a = 1 and t2.a = 1 and t1.avg = t2.avg; Column 'a' has only 2 distinct values, so the filter a=1 on the 1000-row CTE output should estimate ~500 rows (assuming these values are equally common). Instead, the CTE scan estimates 5 rows (1000 * 1/200) because examine_simple_variable returns early when the subquery has GROUP BY, and selectivity estimation falls back on 1/DEFAULT_NUM_DISTINCT. -> CTE Scan on cte t1 (cost=0.00..22.50 rows=5 width=40) (actual time=4.874..5.053 rows=500.00 loops=1) As a result, this query ends up with a Nested Loop plan, and the Execution Time is 192.907 ms. For DISTINCT or GROUP BY key columns that are simple Vars, I think we can propagate stadistinct from the base table, because the set of distinct values is preserved after grouping. MCV frequencies, histograms, and correlation data are not valid since GROUP BY and DISTINCT change the frequency distribution, but with stadistinct alone, callers like var_eq_const() can use a 1/ndistinct estimate rather than 1/DEFAULT_NUM_DISTINCT. Attached is a patch to do this. With the patch, the example above estimates 500 rows ... -> CTE Scan on cte t1 (cost=0.00..22.50 rows=500 width=40) (actual time=3.785..4.143 rows=500.00 loops=1) ... and chooses a Hash Join, with an Execution Time of 8.238 ms (~20x faster). I tested this patch on TPC-DS query 31: -- on master: Planning Time: 5.207 ms Execution Time: 1536140.258 ms -- on patched: Planning Time: 5.140 ms Execution Time: 1149.482 ms Over 1300x faster. Does this approach make sense? Any thoughts? [1] https://postgr.es/m/CAMbWs4-QU_nkFqFZLdzWRsEsVE8aLWx4qBBVq7g4rXw+cvYDMg@mail.gmail.com - Richard -
Re: Propagate stadistinct through GROUP BY/DISTINCT in subqueries and CTEs
wenhui qiu <qiuwenhuifx@gmail.com> — 2026-04-13T03:27:39Z
HI Richard > + /* Convert negative stadistinct to absolute count */ > + if (stats->stadistinct < 0) > + { > + RelOptInfo *baserel = find_base_rel(subroot, var->varno); > + > + if (baserel->tuples > 0) > + { > + stats->stadistinct = (float4) > + clamp_row_est(-stats->stadistinct * baserel->tuples); > + } > + } Thanks so much for working on this! While looking at the negative stadistinct conversion, I was wondering if we might run into a potential edge case with multi-level nested subqueries. What do you think? /* Convert negative stadistinct to absolute count */ if (stats->stadistinct < 0) { - RelOptInfo *baserel = find_base_rel(subroot, var->varno); + RelOptInfo *baserel = vardata->rel; - if (baserel->tuples > 0) + if (baserel && baserel->tuples > 0) { stats->stadistinct = (float4) clamp_row_est(-stats->stadistinct * baserel->tuples); } } Thanks On Mon, Apr 13, 2026 at 9:19 AM Richard Guo <guofenglinux@gmail.com> wrote: > I complained in [1] that some TPC-DS queries suffer from very poor > cardinality estimates on CTE scan filters, to the point that simply > disabling nestloop makes some queries run hundreds of times faster. > Here's a simple reproduction: > > create table t (a int, b int, c int); > insert into t select i%2, i, i from generate_series(1,1000) i; > analyze t; > > explain analyze > with cte as (select a, b, avg(c) as avg from t group by a, b) > select * from cte t1, cte t2 > where t1.a = 1 and t2.a = 1 and t1.avg = t2.avg; > > Column 'a' has only 2 distinct values, so the filter a=1 on the > 1000-row CTE output should estimate ~500 rows (assuming these values > are equally common). Instead, the CTE scan estimates 5 rows (1000 * > 1/200) because examine_simple_variable returns early when the subquery > has GROUP BY, and selectivity estimation falls back on > 1/DEFAULT_NUM_DISTINCT. > > -> CTE Scan on cte t1 (cost=0.00..22.50 rows=5 width=40) > (actual time=4.874..5.053 rows=500.00 loops=1) > > As a result, this query ends up with a Nested Loop plan, and the > Execution Time is 192.907 ms. > > For DISTINCT or GROUP BY key columns that are simple Vars, I think we > can propagate stadistinct from the base table, because the set of > distinct values is preserved after grouping. MCV frequencies, > histograms, and correlation data are not valid since GROUP BY and > DISTINCT change the frequency distribution, but with stadistinct > alone, callers like var_eq_const() can use a 1/ndistinct estimate > rather than 1/DEFAULT_NUM_DISTINCT. > > Attached is a patch to do this. With the patch, the example above > estimates 500 rows ... > > -> CTE Scan on cte t1 (cost=0.00..22.50 rows=500 width=40) > (actual time=3.785..4.143 rows=500.00 loops=1) > > ... and chooses a Hash Join, with an Execution Time of 8.238 ms (~20x > faster). > > I tested this patch on TPC-DS query 31: > > -- on master: > Planning Time: 5.207 ms > Execution Time: 1536140.258 ms > > -- on patched: > Planning Time: 5.140 ms > Execution Time: 1149.482 ms > > Over 1300x faster. > > Does this approach make sense? Any thoughts? > > [1] > https://postgr.es/m/CAMbWs4-QU_nkFqFZLdzWRsEsVE8aLWx4qBBVq7g4rXw+cvYDMg@mail.gmail.com > > - Richard > -
Re: Propagate stadistinct through GROUP BY/DISTINCT in subqueries and CTEs
Richard Guo <guofenglinux@gmail.com> — 2026-04-14T06:11:06Z
On Mon, Apr 13, 2026 at 12:27 PM wenhui qiu <qiuwenhuifx@gmail.com> wrote: > Thanks so much for working on this! While looking at the negative stadistinct conversion, I was wondering if we might run into a potential edge case with multi-level nested subqueries. What do you think? > > /* Convert negative stadistinct to absolute count */ > > if (stats->stadistinct < 0) > { > - RelOptInfo *baserel = find_base_rel(subroot, var->varno); > + RelOptInfo *baserel = vardata->rel; > > - if (baserel->tuples > 0) > + if (baserel && baserel->tuples > 0) > { > stats->stadistinct = (float4) > clamp_row_est(-stats->stadistinct * baserel->tuples); > } > } I don't think your proposed change would work. vardata->rel is the CTE/subquery scan rel in the outer query, and its tuples count is the CTE's output row count, not the base table's. Using it would be equivalent to not converting at all, since get_variable_numdistinct() already computes -stadistinct * vardata->rel->tuples. What we need here is the base table's rel in the subroot, which gives us the correct rowcount for interpreting the negative fraction. - Richard -
Re: Propagate stadistinct through GROUP BY/DISTINCT in subqueries and CTEs
wenhui qiu <qiuwenhuifx@gmail.com> — 2026-04-14T07:58:51Z
Hi Richard > I don't think your proposed change would work. vardata->rel is the > CTE/subquery scan rel in the outer query, and its tuples count is the > CTE's output row count, not the base table's. Using it would be > equivalent to not converting at all, since get_variable_numdistinct() > already computes -stadistinct * vardata->rel->tuples. What we need > here is the base table's rel in the subroot, which gives us the > correct rowcount for interpreting the negative fraction. Thank you for your explanation. The path LGTM Thanks On Tue, Apr 14, 2026 at 2:11 PM Richard Guo <guofenglinux@gmail.com> wrote: > On Mon, Apr 13, 2026 at 12:27 PM wenhui qiu <qiuwenhuifx@gmail.com> wrote: > > > Thanks so much for working on this! While looking at the negative > stadistinct conversion, I was wondering if we might run into a potential > edge case with multi-level nested subqueries. What do you think? > > > > /* Convert negative stadistinct to absolute count */ > > > > if (stats->stadistinct < 0) > > { > > - RelOptInfo *baserel = find_base_rel(subroot, var->varno); > > + RelOptInfo *baserel = vardata->rel; > > > > - if (baserel->tuples > 0) > > + if (baserel && baserel->tuples > 0) > > { > > stats->stadistinct = (float4) > > clamp_row_est(-stats->stadistinct * baserel->tuples); > > } > > } > > I don't think your proposed change would work. vardata->rel is the > CTE/subquery scan rel in the outer query, and its tuples count is the > CTE's output row count, not the base table's. Using it would be > equivalent to not converting at all, since get_variable_numdistinct() > already computes -stadistinct * vardata->rel->tuples. What we need > here is the base table's rel in the subroot, which gives us the > correct rowcount for interpreting the negative fraction. > > - Richard > -
Re: Propagate stadistinct through GROUP BY/DISTINCT in subqueries and CTEs
Richard Guo <guofenglinux@gmail.com> — 2026-07-07T05:31:46Z
On Mon, Apr 13, 2026 at 10:18 AM Richard Guo <guofenglinux@gmail.com> wrote: > For DISTINCT or GROUP BY key columns that are simple Vars, I think we > can propagate stadistinct from the base table, because the set of > distinct values is preserved after grouping. MCV frequencies, > histograms, and correlation data are not valid since GROUP BY and > DISTINCT change the frequency distribution, but with stadistinct > alone, callers like var_eq_const() can use a 1/ndistinct estimate > rather than 1/DEFAULT_NUM_DISTINCT. > > Attached is a patch to do this. Upon self-review, I realized that stanullfrac wasn't adjusted. Since grouping collapses NULL values, we should account for that here. Attached is v2 addressing this. For a single grouping key, at most one NULL group remains, so we set it to 1 / (ndistinct + 1). With multiple grouping keys, the surviving NULL count is underdetermined, so we approximate it as zero; NULLs collapse far more aggressively than non-NULLs, so the real fraction is well below the base table's, and erring low keeps estimates on the hash-join-favoring side. - Richard
-
Re: Propagate stadistinct through GROUP BY/DISTINCT in subqueries and CTEs
Richard Guo <guofenglinux@gmail.com> — 2026-07-08T00:57:24Z
On Tue, Jul 7, 2026 at 2:31 PM Richard Guo <guofenglinux@gmail.com> wrote: > Attached is v2 addressing this. For a single grouping key, at most > one NULL group remains, so we set it to 1 / (ndistinct + 1). With > multiple grouping keys, the surviving NULL count is underdetermined, > so we approximate it as zero; NULLs collapse far more aggressively > than non-NULLs, so the real fraction is well below the base table's, > and erring low keeps estimates on the hash-join-favoring side. I've pushed the v2 patch. - Richard