Skip to content
Merged
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,24 @@ true until the next version shipped.

### Changed

- `GROUP BY` is vectorized by default (#1292).
`pgcolumnar.enable_group_vectorization` is now `on`. The planner takes the
grouped path only when an upper bound on the group count is within
`pgcolumnar.groupagg_max_groups`: the table's live row count, or, for
time-bucket keys, the buckets #369's and #1304's bounds allow. Above that the
ordinary plan runs. Before, the path was off because over the cap it errors at
execution, and on by default that error would have reached any high-cardinality
`GROUP BY`. The first version gated on the estimate, and @OffgridwithJD's review
showed a skewed key fooling ANALYZE by 68x with fresh statistics (29,356
estimated for 2,000,001) and erroring by default; a bound cannot be fooled so.
The runtime cap stays as the backstop for a cap lowered, or a table grown,
after planning; `native_groupagg` shows it with a cached plan. Three suites assumed the old default: `native_groupagg` forced the
node under a cap of 100, which the gate now declines; `groupagg_table_sizing`
raises the cap for its 200,000-group fixture; and `vector_agg_tlist_shape`
pins the grouped path off, because it tests the ungrouped fold. The docs said
the path took only plain columns and `count`, `sum` and `avg`; it also takes
`date_bin`, `date_trunc`, `min` and `max`.

- The planner estimates the number of groups for a time-bucket key from the
table's own data (#1292). `GROUP BY date_trunc(unit, ts)` and, newly,
`GROUP BY date_bin(stride, ts, origin)` were estimated near the row count
Expand Down
32 changes: 20 additions & 12 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ disk. It never changes the values that a table returns.
| `pgcolumnar.enable_custom_scan` | boolean | `on` | Use the columnar custom scan path for columnar tables. |
| `pgcolumnar.enable_qual_pushdown` | boolean | `on` | Push scan qualifiers down so per-chunk min and max values can skip chunk groups. |
| `pgcolumnar.enable_vectorization` | boolean | `on` | Use the vectorized aggregate path for supported ungrouped aggregates. |
| `pgcolumnar.enable_group_vectorization` | boolean | `off` | Use the vectorized aggregate path for `GROUP BY` queries on a columnar table. Off by default; see [why grouped vectorization is off by default](#why-grouped-vectorization-is-off-by-default). |
| `pgcolumnar.groupagg_max_groups` | integer | `1000000` | Cap on the group count the grouped vectorized aggregate builds. Over the cap the query errors. Range 1 to INT_MAX. |
| `pgcolumnar.enable_group_vectorization` | boolean | `on` | Use the vectorized aggregate path for `GROUP BY` queries on a columnar table. The planner takes it only when the group count cannot exceed `pgcolumnar.groupagg_max_groups`; see [how the grouped path stays safe on by default](#how-the-grouped-path-stays-safe-on-by-default). |
| `pgcolumnar.groupagg_max_groups` | integer | `1000000` | Cap on the group count the grouped vectorized aggregate builds. Over the cap the query errors. The planner takes the grouped path only when the group count cannot exceed this. Range 1 to INT_MAX. |
| `pgcolumnar.enable_bloom_filter` | boolean | `on` | Skip chunk groups on equality filters using per-chunk bloom filters. |
| `pgcolumnar.enable_join_runtime_filter` | boolean | `on` | Wrap a serial inner Hash Join so the build keys can skip fact-table groups and reject non-matching rows. Direct columnar scan only. Group skip needs the fact table clustered on the join key. On by default. |
| `pgcolumnar.enable_read_stream` | boolean | `on` | Prefetch block reads with the read stream API. Effective on PostgreSQL 17 and later. |
Expand Down Expand Up @@ -192,7 +192,7 @@ limit or a level.
clear a declared retention, set `ttl_interval` to a value long enough that no
row reaches it, or recreate the table.

### Why grouped vectorization is off by default
### How the grouped path stays safe on by default

`pgcolumnar.enable_group_vectorization` is a real speed-up. Take 4,000,000 rows
grouped into 200,000 keys. `SELECT k, sum(m) FROM t GROUP BY k` runs in 403.7 ms
Expand All @@ -204,8 +204,8 @@ reset, and the ordinary `Agg` resets far more contexts than the vectorized fold
does. Your own factor depends on the build, the group count, the key width and
the aggregate.

It stays off because of how it fails, not because of how it performs. The
grouped path builds a hash table that does not spill, so
Until 1.0-alpha5 it was off by default, because of how it fails, not how it
performs. The grouped path builds a hash table that does not spill, so
`pgcolumnar.groupagg_max_groups` bounds it at 1,000,000 groups. The cap is
checked during execution, against the real group count. By then the plan is
fixed, and the path cannot hand the query back to an ordinary `Agg`. Over the
Expand All @@ -216,14 +216,22 @@ ERROR: 54000: grouped vectorized aggregate exceeded pgcolumnar.groupagg_max_gro
HINT: Raise pgcolumnar.groupagg_max_groups, or set pgcolumnar.enable_group_vectorization = off.
```

Measured on a table with 1,500,000 distinct keys: the same query succeeds with
the setting off and raises that error with it on.
On by default, that error would reach tables nobody chose the path for. So the
planner now takes the grouped path only when the group count cannot exceed the
cap. It uses an upper bound, not an estimate:

A default carries that behaviour to tables the user did not choose it for. The
failure appears when a table grows past the cap. A query that ran yesterday then
fails today, with no change to the query and no change to the setting. Turn the
setting on for a workload whose group count you know. Raise the cap for one that
approaches it.
- the table's live row count, because no grouping makes more groups than rows;
- for `date_bin` and `date_trunc` keys, the number of buckets the time column's
range allows, which is usually far smaller.

A query over more rows than the cap, grouped by a plain key, runs the ordinary
plan and succeeds. An estimate could not be the gate. On a key where one value
fills half the table and the rest are unique, a fresh `ANALYZE` estimated 29,356
groups for 2,000,001.

The error remains possible only when something changes after planning: the cap
is lowered, or the table grows past it. A prepared statement can see this. To keep larger
tables on the fast path, raise `pgcolumnar.groupagg_max_groups`.

### Encode effort

Expand Down
15 changes: 10 additions & 5 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,16 @@ stronger codec writes less.
Neither path uses the per-tuple executor path. `count(*)` with no filter is one case of
this: it is answered from each row group's stored row count and reads no column
data. Set `pgcolumnar.enable_vectorization` to `off` to force an ordinary
aggregate over the scan instead. An opt-in path vectorizes `GROUP BY` too. Set
`pgcolumnar.enable_group_vectorization` to `on` to group and aggregate in one
pass over the reader. It is off by default. That pass applies where the
query groups by plain columns, aggregates with `count`, `sum` or `avg`, and
filters only with comparisons the scan keys express. There it reads each
aggregate over the scan instead. `GROUP BY` is vectorized too, by default. The
scan groups and aggregates in one pass over the reader. The planner takes this
path only when the groups cannot exceed `pgcolumnar.groupagg_max_groups`. The
table's row count bounds them, and so do the time buckets for a `date_bin` or
`date_trunc` key. It takes
plain columns and `date_bin` or `date_trunc` keys, and `count`, `sum`, `avg`,
`min` and `max`. Set `pgcolumnar.enable_group_vectorization` to `off` to turn
it off. Within it, the batch fold needs plain-column
keys and `count`, `sum` or `avg`. It also needs a filter of only the
comparisons the scan keys express. There it reads each
column's values directly instead of one row at a time. `EXPLAIN` reports
`Columnar Batch Fold` on the node. Other shapes read row at a time and return
the same results.
Expand Down
14 changes: 8 additions & 6 deletions docs/how-to.md
Original file line number Diff line number Diff line change
Expand Up @@ -906,17 +906,19 @@ limitations guide.
## Vectorize a GROUP BY aggregate

The vectorized aggregate path computes an ungrouped aggregate over decoded
vectors. An opt-in setting extends it to `GROUP BY`.
vectors. It also covers `GROUP BY`, on by default, when the planner estimates a
bounded number of groups.

```sql
SET pgcolumnar.enable_group_vectorization = on;
EXPLAIN (ANALYZE) SELECT region, sum(amount) FROM events GROUP BY region;
```

**Tuning.** Turn it on for a `GROUP BY` with a bounded number of distinct groups.
The plan then shows `Columnar Vectorized Group Keys`. A query that exceeds
`pgcolumnar.groupagg_max_groups` (default 1000000) raises an error, so leave the
setting off for high-cardinality grouping.
**Tuning.** The plan shows `Columnar Vectorized Group Keys` when the path is
taken. The planner takes it only when the group count cannot exceed
`pgcolumnar.groupagg_max_groups` (default 1000000). For a plain key the bound is
the table's row count, so a table of more rows than the cap gets the ordinary
plan. For time-bucket keys the bound is the number of buckets. Raise the cap to
keep larger tables on the fast path.

## Count rows quickly

Expand Down
12 changes: 7 additions & 5 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -826,17 +826,19 @@ A dimension with duplicate keys stays on the core Agg plan.
A LEFT join stays on the core Agg plan.
Grouped aggregation over a join is not this path.

A separate opt-in path vectorizes `GROUP BY`. It is off by default. Set
`pgcolumnar.enable_group_vectorization` to `on` to enable it. It covers
A separate path vectorizes `GROUP BY`. It is on by default
(`pgcolumnar.enable_group_vectorization`). The planner takes it only when the
group count cannot exceed `pgcolumnar.groupagg_max_groups`. The table's row count
bounds it, and so do the time buckets for a time-bucket key. It covers
`SELECT <keys>, agg(col) ... [WHERE ...] GROUP BY <keys>` on one columnar
relation. Each key must be non-volatile, hashable, and computable from that
relation. A collatable key must use a deterministic collation. Each output must
be a supported aggregate or a bare key reference. This path also accepts `sum`
and `avg` on `bigint`, `numeric`, and floating-point columns, which the ungrouped
path rejects. `pgcolumnar.groupagg_max_groups` caps the group count, default
1,000,000. The cap is checked at execution against the real count, so a query
that exceeds it errors rather than switching plans. Raise the cap or turn the
path off.
1,000,000. The cap is checked again at execution against the real count. A
plan made before the cap was lowered, or before the table grew past it, errors
there; it cannot switch plans. Raise the cap, or turn the path off.


## Join runtime filter
Expand Down
18 changes: 12 additions & 6 deletions src/columnar_tableam.c
Original file line number Diff line number Diff line change
Expand Up @@ -4072,9 +4072,12 @@ _PG_init(void)

DefineCustomBoolVariable("pgcolumnar.enable_group_vectorization",
"Use the vectorized aggregate fast path for GROUP BY queries.",
NULL,
"Taken only when an upper bound on the group count (the "
"row count, or the time buckets) is within "
"pgcolumnar.groupagg_max_groups; otherwise the ordinary "
"plan runs.",
&pgcolumnar_enable_group_vectorization,
false,
true,
PGC_USERSET,
0,
NULL, NULL, NULL);
Expand Down Expand Up @@ -4104,10 +4107,13 @@ _PG_init(void)
DefineCustomIntVariable("pgcolumnar.groupagg_max_groups",
"Cap on the actual group count the grouped vectorized "
"aggregate builds before it stops with an error.",
"Enforced at execution against the real number of groups, "
"not the planner's estimate: over the cap the query errors "
"rather than falling back, since the plan is fixed by then. "
"Raise it, or turn off pgcolumnar.enable_group_vectorization.",
"Enforced at execution against the real number of groups: "
"over the cap the query errors rather than falling back, "
"since the plan is fixed by then. The planner takes the "
"grouped path only when an upper bound on the group count "
"is within it, so the error means it fell, or the table "
"grew, after planning. Raise it, or turn off "
"pgcolumnar.enable_group_vectorization.",
&pgcolumnar_groupagg_max_groups,
1000000, 1, INT_MAX,
PGC_USERSET,
Expand Down
119 changes: 92 additions & 27 deletions src/columnar_vector.c
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,15 @@
bool pgcolumnar_enable_vectorization = true;

/*
* GUC: extend the vectorized aggregate to GROUP BY (#289). Default off while the
* grouped path is built out incrementally; the ungrouped path is unaffected.
* groupagg_max_groups caps the ACTUAL group count at execution time; exceeding
* it raises an error (see pgcolumnar_groupagg_lookup) rather than routing to core
* HashAgg. It is not a plan-time gate.
* GUC: extend the vectorized aggregate to GROUP BY (#289). On by default since
* #1292 phase 2, because the planner now takes the path only when an UPPER
* BOUND on the group count is within groupagg_max_groups (see
* pgcolumnar_group_count_upper_bound). groupagg_max_groups still caps the
* ACTUAL group count at execution time; exceeding it raises an error (see
* pgcolumnar_groupagg_lookup) rather than routing to core HashAgg, which now
* needs the cap lowered, or the table grown past it, after planning.
*/
bool pgcolumnar_enable_group_vectorization = false;
bool pgcolumnar_enable_group_vectorization = true;
int pgcolumnar_groupagg_max_groups = 1000000;

/*
Expand Down Expand Up @@ -2367,21 +2369,19 @@ pgcolumnar_truncating_time_bound(PlannerInfo *root, RelOptInfo *input_rel,
* A query with no such bound gets no bound from us.
*/
static double
pgcolumnar_group_estimate_bound(PlannerInfo *root, RelOptInfo *input_rel,
List *groupExprs)
pgcolumnar_group_estimate_bound(PlannerInfo *root, List *groupExprs,
double timeProduct)
{
ListCell *lc;
double bound = 1.0;

if (groupExprs == NIL)
if (groupExprs == NIL || timeProduct <= 0.0 || timeProduct >= (double) INT_MAX)
return 0.0;

foreach(lc, groupExprs)
{
Node *expr = (Node *) lfirst(lc);
VariableStatData vardata;
bool informed;
double one;

/*
* Core's own question: does anything actually know about this expression?
Expand All @@ -2392,16 +2392,64 @@ pgcolumnar_group_estimate_bound(PlannerInfo *root, RelOptInfo *input_rel,
ReleaseVariableStats(vardata);
if (informed)
return 0.0;
}
return timeProduct;
}

one = pgcolumnar_truncating_time_bound(root, input_rel, expr);
if (one <= 0.0)
return 0.0; /* one unbounded key and the product is unbounded */
/*
* pgcolumnar_time_bucket_products
* Each key's time-bucket bound (pgcolumnar_truncating_time_bound), read ONCE
* per plan, because each reads the zone maps (#1304: once per plan, not once
* per row group, and not twice). *product is their product, and *withNull
* the product with one more group per key for NULL; both are 0 when any key
* has no bound.
*/
static void
pgcolumnar_time_bucket_products(PlannerInfo *root, RelOptInfo *input_rel,
List *groupExprs, double *product, double *withNull)
{
ListCell *lc;

bound *= one;
if (bound >= (double) INT_MAX)
return 0.0;
*product = 1.0;
*withNull = 1.0;
foreach(lc, groupExprs)
{
double one = pgcolumnar_truncating_time_bound(root, input_rel,
(Node *) lfirst(lc));

if (one <= 0.0)
{
*product = 0.0;
*withNull = 0.0;
return;
}
*product *= one;
*withNull *= one + 1.0;
}
return bound;
}

/*
* pgcolumnar_group_count_upper_bound
* A TRUE upper bound on the number of groups, for the gate (#1292 phase 2).
*
* The relation's live row count always bounds it: no grouping makes more
* groups than rows. input_rel->tuples is that count, read from the
* row-group catalog and the delete vectors (relation_estimate_size), not
* ANALYZE's sample, and before any WHERE, whose selectivity is an estimate.
*
* Where EVERY key is a truncating time function with a bound from its
* range (#369's quals, #1304's zone maps), the product of those bounds,
* with one more group per key for NULL, is tighter (timeWithNull, from
* pgcolumnar_time_bucket_products). Unlike pgcolumnar_group_estimate_bound,
* this applies even to a key that has statistics, because statistics are
* an estimate and the buckets a bound.
*/
static double
pgcolumnar_group_count_upper_bound(RelOptInfo *input_rel, double timeWithNull)
{
double rows = Max(input_rel->tuples, 0.0);

return (timeWithNull > 0.0 && timeWithNull < rows) ? timeWithNull : rows;
}

static void
Expand All @@ -2417,6 +2465,8 @@ PgColumnarTryGroupAggPath(PlannerInfo *root, RelOptInfo *input_rel,
ListCell *lc;
int naggs = 0;
double dNumGroups;
double timeProduct;
double timeWithNull;
Path *cheapest;
Cost serialScanCost = 0.0;
double serialScanRows = 0.0;
Expand Down Expand Up @@ -2462,13 +2512,11 @@ PgColumnarTryGroupAggPath(PlannerInfo *root, RelOptInfo *input_rel,

/*
* estimate_num_groups only sizes the path's row estimate; it is deliberately
* NOT a gate. For an expression key such as date_trunc(...) the planner
* cannot estimate distinctness and returns a count near the input row count
* (here ~8.4M estimated against 48k actual), which would wrongly disable this
* path on exactly the large tables it helps. The unbounded-hash-table guard
* is the execution-time cap on the actual group count
* (pgcolumnar.groupagg_max_groups), which errors with guidance -- see
* pgcolumnar_groupagg_lookup -- rather than silently declining the feature.
* NOT the gate. For an expression key such as date_trunc(...) it cannot see
* distinctness and returns a count near the input row count (~8.4M estimated
* against 48k actual), and for a skewed plain key ANALYZE's own estimator
* can be 68x LOW with fresh statistics (#1330 review: 29,356 estimated for
* 2,000,001). The gate (THE GATE below) uses an upper bound instead.
*/
groupExprs = groupKeys;
dNumGroups = estimate_num_groups(root, groupExprs,
Expand Down Expand Up @@ -2499,14 +2547,31 @@ PgColumnarTryGroupAggPath(PlannerInfo *root, RelOptInfo *input_rel,
* downward: Min() cannot raise an estimate, so a shape we get wrong is priced
* no worse than it is today.
*/
pgcolumnar_time_bucket_products(root, input_rel, groupExprs,
&timeProduct, &timeWithNull);
{
double bound = pgcolumnar_group_estimate_bound(root, input_rel,
groupExprs);
double bound = pgcolumnar_group_estimate_bound(root, groupExprs,
timeProduct);

if (bound > 0.0 && bound < dNumGroups)
dNumGroups = Max(1.0, bound);
}

/*
* THE GATE (#1292 phase 2, the owner's decision: gate now, never error
* later): take the path only when an UPPER BOUND on the group count is
* within the runtime cap. Over the cap the node errors, since its plan is
* fixed by then, so with the path on by default a gate that let a group
* count past the cap would turn a working query into an error. An estimate
* cannot be that gate: a skewed key fools ANALYZE (see above). A bound can,
* and the default then never errors; the cap is reached only if it falls,
* or the table grows past it, after planning. A user who wants more groups
* on this path raises groupagg_max_groups.
*/
if (pgcolumnar_group_count_upper_bound(input_rel, timeWithNull) >
(double) pgcolumnar_groupagg_max_groups)
return;

/*
* Pseudoconstant (gating) quals -- e.g. WHERE (SELECT false) -- are one-time
* filters, not per-row predicates. The residual ExecQual recheck this path
Expand Down
4 changes: 3 additions & 1 deletion test/groupagg_table_sizing.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ psql_run "INSERT INTO gts_expr SELECT '2026-01-01'::timestamptz + (i || ' second
FROM generate_series(1,500000) i;"
psql_run "ANALYZE gts_expr;"

ON="SET pgcolumnar.enable_group_vectorization=on;"
# The cap is raised so the gate (a tenth of it, #1292 phase 2) admits the 200,000-group
# fixture: this suite measures the hash table's sizing, not the plan choice.
ON="SET pgcolumnar.enable_group_vectorization=on; SET pgcolumnar.groupagg_max_groups=10000000;"
BIG="SET work_mem='64MB';"

explain_with() { # $1 = extra SET, $2 = query
Expand Down
Loading
Loading