From 48531f36f8d4039ccd52253486b7c498242cae9a Mon Sep 17 00:00:00 2001 From: "Joshua (D) Drake" <136637981+ChronicallyJD@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:26:18 +0000 Subject: [PATCH 1/3] Keep the ungrouped vectorized aggregate over a unique-key inner join. A joinrel used to drop the fold even when the dimension was a unique filter of the fact table. Duplicate-key dimensions and LEFT joins still use core Agg. --- CHANGELOG.md | 10 + docs/configuration.md | 2 +- docs/features.md | 3 + docs/how-to.md | 16 + docs/limitations.md | 14 +- docs/user-guide.md | 1 + src/columnar_vector.c | 591 +++++++++++++++++++++++++++- test/check_ledger.tsv | 8 + test/check_ledger_budget.txt | 2 +- test/native_join_vector_agg.sh | 92 +++++ test/pytest/TESTS.md | 25 ++ test/pytest/test_join_vector_agg.py | 140 +++++++ test/run_all_versions.sh | 1 + 13 files changed, 882 insertions(+), 23 deletions(-) create mode 100644 test/native_join_vector_agg.sh create mode 100644 test/pytest/test_join_vector_agg.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 149eb93c..cbfbb093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ true until the next version shipped. ### Added +- Ungrouped vectorized aggregate over a unique-key inner Hash Join (#752). + + The fold used to require a single base relation, so a star-schema join dropped it. + A unique dimension is a filter of the fact table, so the fold can keep running. + Duplicate-key dimensions, LEFT joins, and grouped aggregation over a join still use core Agg. + `pgcolumnar.enable_ungrouped_vector_agg` stays off by default. + + checks_never_observed_red 1155 -> 1162 + covered native_join_vector_agg, 8 checks, one last-red 2026-09-12 + - The mutation ledger covers a third suite: `differential`, 204 checks (#752). suites_not_covered 250 -> 249 diff --git a/docs/configuration.md b/docs/configuration.md index ddab390c..2aa2cb7e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -63,7 +63,7 @@ disk. It never changes the values that a table returns. | `pgcolumnar.enable_bloom_filter` | boolean | `on` | Skip chunk groups on equality filters using per-chunk bloom filters. | | `pgcolumnar.enable_join_runtime_filter` | boolean | `off` | Wrap a serial inner Hash Join so the build keys can skip fact-table groups and reject non-matching rows. Direct columnar scan only. Off until the skip is measured. | | `pgcolumnar.enable_read_stream` | boolean | `on` | Prefetch block reads with the read stream API. Effective on PostgreSQL 17 and later. | -| `pgcolumnar.enable_ungrouped_vector_agg` | boolean | `off` | Answer an ungrouped aggregate (`count`, `sum`, `avg`, `min`, `max` with no `GROUP BY`) with a batch fold over decoded vectors instead of row-at-a-time. Off by default. | +| `pgcolumnar.enable_ungrouped_vector_agg` | boolean | `off` | Answer an ungrouped aggregate (`count`, `sum`, `avg`, `min`, `max` with no `GROUP BY`) with a batch fold over decoded vectors instead of row-at-a-time. A unique-key inner Hash Join can keep that fold. Off by default. | | `pgcolumnar.enable_parallel_vector_agg` | boolean | `off` | Let the ungrouped batch fold run as a parallel partial aggregate under `Gather`, each worker folding its own row groups. Requires `pgcolumnar.enable_ungrouped_vector_agg`. Off by default. | | `pgcolumnar.enable_column_projection` | boolean | `on` | Read only the columns a query references rather than every column of the row group. | | `pgcolumnar.enable_index_fetch_penalty` | boolean | `on` | Charge a columnar index scan for the row-group decode its per-row heap fetches force, so the planner does not treat a columnar fetch as if it were a heap page read. Set to `off` to restore the pre-1.0-alpha planner behaviour. | diff --git a/docs/features.md b/docs/features.md index 7d3dad9b..37b9b9f0 100644 --- a/docs/features.md +++ b/docs/features.md @@ -75,6 +75,9 @@ settings see the [configuration reference](configuration.md); for constraints se It can also reject non-matching rows with a Bloom filter of those keys. The GUC `pgcolumnar.enable_join_runtime_filter` is off by default. It does not wrap LEFT, SEMI, ANTI, CROSS, parallel, or projection scans. +- Ungrouped vectorized aggregate over a unique-key inner Hash Join. + A unique dimension is a filter of the fact table, so the fold can keep running. + Duplicate-key dimensions and LEFT joins stay on the core Agg plan. - Parallel scan across a table's row groups. - Read stream prefetch of block reads on PostgreSQL 17 and later (`pgcolumnar.enable_read_stream`). diff --git a/docs/how-to.md b/docs/how-to.md index 46f112cd..234b7927 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -482,6 +482,22 @@ EXPLAIN (ANALYZE) SELECT count(*) FROM events; surviving rows and decodes the filtered columns. Keep the filter on a sorted or bloomed column so chunk-group skipping removes most groups first. +## Fold an aggregate over a unique-key join + +An ungrouped aggregate over a unique-key inner Hash Join can keep the +vectorized fold. + +```sql +SET pgcolumnar.enable_ungrouped_vector_agg = on; +EXPLAIN (COSTS OFF) +SELECT count(*), sum(fact.amount) +FROM fact JOIN dim ON fact.k = dim.k; +``` + +**Tuning.** The dimension needs a unique constraint on the join key. +A duplicate-key dimension keeps the core Agg plan. +Grouped aggregation over a join is not this path. + ## Measure and introspect Inspect physical layout, sort quality, and query plans. diff --git a/docs/limitations.md b/docs/limitations.md index d56e8a60..94dafd0b 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -642,8 +642,9 @@ refuse it. ## Vectorized aggregate coverage -The vectorized aggregate path covers one shape only. That shape is -`SELECT agg(col) FROM t [WHERE ...]`, on one relation and with no grouping. +The vectorized aggregate path covers one ungrouped shape. That shape is +`SELECT agg(col) FROM t [WHERE ...]`, on one relation or a unique-key inner +Hash Join, with no grouping. The target list may contain expressions over those aggregates. `count(*)::text`, `avg(a)+avg(b)`, `round(avg(a), 2)` and `max(a)-min(a)` all take the path. What @@ -664,9 +665,16 @@ Each other query uses the scalar plan and stays correct. These include `sum` or - aggregates with `DISTINCT` - `GROUP BY` (unless the opt-in grouped path below is enabled) and `HAVING` - filters that are not simple -- joins +- joins other than a unique-key inner Hash Join - a reference to a whole row or to a system column +A unique-key inner Hash Join is a filter of the fact table. +The ungrouped fold can run on that shape when +`pgcolumnar.enable_ungrouped_vector_agg` is on. +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 `SELECT , agg(col) ... [WHERE ...] GROUP BY ` on one columnar diff --git a/docs/user-guide.md b/docs/user-guide.md index 39dfb5c4..fbb4b048 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -138,6 +138,7 @@ controlled by a setting in the [Configuration reference](configuration.md): avg, min, or max on a supported type. - `count(*)` answered from catalog metadata when there is no filter. - Join runtime filter: a serial inner Hash Join can skip fact-table groups and reject non-matching rows using the build-side keys. +- Vectorized aggregate over a unique-key inner Hash Join when the ungrouped fold is on. #### Reading the filter counters diff --git a/src/columnar_vector.c b/src/columnar_vector.c index ba2a8994..7a5f9f36 100644 --- a/src/columnar_vector.c +++ b/src/columnar_vector.c @@ -69,6 +69,8 @@ #include "optimizer/planner.h" #include "optimizer/cost.h" #include "optimizer/restrictinfo.h" +#include "optimizer/planmain.h" +#include "parser/parsetree.h" #include "optimizer/tlist.h" #include "utils/array.h" #include "access/sysattr.h" @@ -627,8 +629,35 @@ typedef struct PgColumnarAggScanState * state is ended before EXPLAIN runs. Meaningful only when haveStats. */ int usablePreds; + + /* + * Unique-key inner join fold (#752). joinFactAttno 0 means this node + * is a plain base-relation fold. + */ + AttrNumber joinFactAttno; + AttrNumber joinBuildResno; + PlanState *joinBuildState; + MemoryContext joinFoldContext; + FmgrInfo joinEqFn; + FmgrInfo joinHashFn; + Oid joinCollation; + int16 joinTyplen; + bool joinTypbyval; + bool joinFoldKeysReady; + uint32 joinFoldNslots; + uint32 joinFoldNkeys; + char *joinFoldOccupied; + uint32 *joinFoldHashes; + Datum *joinFoldKeys; } PgColumnarAggScanState; +static AttrNumber pgcolumnar_join_fold_key_resno(Plan *plan, Index varno, + AttrNumber attno); +static void pgcolumnar_join_fold_drain(PgColumnarAggScanState *state); +static void pgcolumnar_join_fold_reset_table(PgColumnarAggScanState *state); +static bool pgcolumnar_join_fold_lookup(PgColumnarAggScanState *state, + Datum value); + static const CustomExecMethods pgcolumnar_agg_exec_methods; static const CustomExecMethods pgcolumnar_agg_parallel_exec_methods; @@ -799,9 +828,37 @@ PgColumnarPlanAggPath(PlannerInfo *root, RelOptInfo *rel, CustomPath *best_path, cscan->scan.plan.qual = NIL; /* WHERE is applied inside the scan */ cscan->scan.scanrelid = 0; /* not a base-relation scan */ cscan->flags = best_path->flags; - cscan->custom_plans = NIL; + cscan->custom_plans = custom_plans; cscan->custom_exprs = NIL; cscan->custom_private = best_path->custom_private; + if (list_length(best_path->custom_private) >= 7) + { + Plan *dimPlan; + AttrNumber resno; + ListCell *lc; + List *priv; + int i; + + if (list_length(custom_plans) != 1) + elog(ERROR, "pgcolumnar join fold expected one dimension plan"); + dimPlan = (Plan *) linitial(custom_plans); + resno = pgcolumnar_join_fold_key_resno(dimPlan, + (Index) intVal(list_nth(best_path->custom_private, 4)), + (AttrNumber) intVal(list_nth(best_path->custom_private, 5))); + if (!AttributeNumberIsValid(resno)) + elog(ERROR, "pgcolumnar join fold could not locate the dimension key"); + priv = NIL; + i = 0; + foreach(lc, best_path->custom_private) + { + if (i == 6) + priv = lappend(priv, makeInteger((int) resno)); + else + priv = lappend(priv, copyObject(lfirst(lc))); + i++; + } + cscan->custom_private = priv; + } cscan->custom_scan_tlist = tlist; /* defines the output tuple shape */ cscan->methods = &pgcolumnar_scan_methods; /* shared registered methods */ @@ -882,6 +939,384 @@ pgcolumnar_parallel_agg_ok(PgColumnarAggKind kind) } } +/* + * Unique-key inner join fold (#752). + * + * A star-schema inner Hash Join onto a unique dimension is a filter of the + * fact table, so the ungrouped vectorized aggregate can keep running. The + * dimension child is drained into an exact key set; fact rows whose join key + * is absent are skipped. Duplicate-key dimensions, LEFT joins, and a grouped + * aggregate over a join are refused and stay on core Agg. + */ +static Node * +pgcolumnar_join_fold_strip(Node *node) +{ + while (node != NULL && IsA(node, RelabelType)) + node = (Node *) ((RelabelType *) node)->arg; + return node; +} + +static bool +pgcolumnar_join_fold_base_scan(Path *path) +{ + CustomPath *customPath; + + if (path == NULL || !IsA(path, CustomPath)) + return false; + customPath = (CustomPath *) path; + return customPath->methods != NULL && + strcmp(customPath->methods->CustomName, "PgColumnarScan") == 0 && + customPath->custom_private == NIL && + path->param_info == NULL && + !path->parallel_aware && + path->parallel_workers == 0; +} + +static HashPath * +pgcolumnar_join_fold_as_hashpath(Path *path) +{ + if (path == NULL) + return NULL; + if (IsA(path, HashPath)) + { + HashPath *hashPath = (HashPath *) path; + + if (hashPath->jpath.jointype == JOIN_INNER && + path->param_info == NULL && + !path->parallel_aware) + return hashPath; + return NULL; + } + if (IsA(path, CustomPath)) + { + CustomPath *customPath = (CustomPath *) path; + Path *child; + + if (customPath->methods == NULL || + strcmp(customPath->methods->CustomName, + "Columnar Runtime Filter Coordinator") != 0) + return NULL; + if (customPath->custom_paths == NIL) + return NULL; + child = (Path *) linitial(customPath->custom_paths); + return pgcolumnar_join_fold_as_hashpath(child); + } + return NULL; +} + +static HashPath * +pgcolumnar_join_fold_hashpath(RelOptInfo *joinrel) +{ + ListCell *lc; + HashPath *hashPath; + + if (joinrel == NULL) + return NULL; + hashPath = pgcolumnar_join_fold_as_hashpath(joinrel->cheapest_total_path); + if (hashPath != NULL) + return hashPath; + foreach(lc, joinrel->pathlist) + { + hashPath = pgcolumnar_join_fold_as_hashpath((Path *) lfirst(lc)); + if (hashPath != NULL) + return hashPath; + } + return NULL; +} + +static bool +pgcolumnar_join_fold_vars(PlannerInfo *root, HashPath *hashPath, + Var **factVarOut, Var **buildVarOut) +{ + RestrictInfo *restrictInfo; + OpExpr *operatorExpr; + Node *left; + Node *right; + Var *factVar; + Var *buildVar; + Relids factRelids; + Relids buildRelids; + RangeTblEntry *rte; + TypeCacheEntry *tce; + + if (list_length(hashPath->path_hashclauses) != 1) + return false; + restrictInfo = linitial_node(RestrictInfo, hashPath->path_hashclauses); + if (!IsA(restrictInfo->clause, OpExpr)) + return false; + operatorExpr = (OpExpr *) restrictInfo->clause; + if (list_length(operatorExpr->args) != 2) + return false; + left = pgcolumnar_join_fold_strip(linitial(operatorExpr->args)); + right = pgcolumnar_join_fold_strip(lsecond(operatorExpr->args)); + if (!IsA(left, Var) || !IsA(right, Var)) + return false; + factRelids = hashPath->jpath.outerjoinpath->parent->relids; + buildRelids = hashPath->jpath.innerjoinpath->parent->relids; + if (bms_is_member(((Var *) left)->varno, factRelids) && + bms_is_member(((Var *) right)->varno, buildRelids)) + { + factVar = (Var *) left; + buildVar = (Var *) right; + } + else if (bms_is_member(((Var *) right)->varno, factRelids) && + bms_is_member(((Var *) left)->varno, buildRelids)) + { + factVar = (Var *) right; + buildVar = (Var *) left; + } + else + return false; + if (factVar->varlevelsup != 0 || buildVar->varlevelsup != 0 || + factVar->varattno <= 0 || buildVar->varattno <= 0) + return false; + if (factVar->vartype != buildVar->vartype) + return false; + rte = planner_rt_fetch(factVar->varno, root); + if (rte == NULL || rte->rtekind != RTE_RELATION || + !PgColumnarIsColumnarRelation(rte->relid)) + return false; + tce = lookup_type_cache(factVar->vartype, + TYPECACHE_EQ_OPR | TYPECACHE_HASH_PROC); + if (!OidIsValid(tce->eq_opr) || !OidIsValid(tce->hash_proc)) + return false; + *factVarOut = factVar; + *buildVarOut = buildVar; + return true; +} + +static bool +pgcolumnar_join_fold_dim_has_key(Path *dimPath, Var *buildVar) +{ + ListCell *lc; + + if (dimPath == NULL || dimPath->pathtarget == NULL) + return false; + foreach(lc, dimPath->pathtarget->exprs) + { + Node *expr = pgcolumnar_join_fold_strip((Node *) lfirst(lc)); + + if (IsA(expr, Var) && + ((Var *) expr)->varno == buildVar->varno && + ((Var *) expr)->varattno == buildVar->varattno) + return true; + } + return false; +} + +static bool +pgcolumnar_join_fold_try(PlannerInfo *root, RelOptInfo *joinrel, + Index *factRtiOut, RelOptInfo **factRelOut, + Path **dimPathOut, AttrNumber *factAttnoOut, + Index *buildVarnoOut, AttrNumber *buildAttnoOut) +{ + HashPath *hashPath; + Var *factVar; + Var *buildVar; + Path *outerPath; + Path *innerPath; + RelOptInfo *innerrel; + RelOptInfo *factRel; + + if (bms_num_members(joinrel->relids) != 2) + return false; + { + ListCell *sjc; + + foreach(sjc, root->join_info_list) + { + SpecialJoinInfo *sj = (SpecialJoinInfo *) lfirst(sjc); + + if (sj->jointype == JOIN_INNER) + continue; + if (bms_is_subset(sj->min_lefthand, joinrel->relids) && + bms_is_subset(sj->min_righthand, joinrel->relids)) + return false; + } + } + hashPath = pgcolumnar_join_fold_hashpath(joinrel); + if (hashPath == NULL) + return false; + outerPath = hashPath->jpath.outerjoinpath; + innerPath = hashPath->jpath.innerjoinpath; + if (!pgcolumnar_join_fold_base_scan(outerPath)) + return false; + if (innerPath == NULL || innerPath->param_info != NULL) + return false; + if (!pgcolumnar_join_fold_vars(root, hashPath, &factVar, &buildVar)) + return false; + if (!pgcolumnar_join_fold_dim_has_key(innerPath, buildVar)) + return false; + innerrel = innerPath->parent; + if (!innerrel_is_unique(root, joinrel->relids, outerPath->parent->relids, + innerrel, JOIN_INNER, hashPath->path_hashclauses, + true)) + return false; + factRel = find_base_rel(root, (int) factVar->varno); + if (factRel == NULL) + return false; + *factRtiOut = factVar->varno; + *factRelOut = factRel; + *dimPathOut = innerPath; + *factAttnoOut = factVar->varattno; + *buildVarnoOut = buildVar->varno; + *buildAttnoOut = buildVar->varattno; + return true; +} + +static AttrNumber +pgcolumnar_join_fold_key_resno(Plan *plan, Index varno, AttrNumber attno) +{ + ListCell *cell; + + foreach(cell, plan->targetlist) + { + TargetEntry *entry = lfirst_node(TargetEntry, cell); + Node *expr = pgcolumnar_join_fold_strip((Node *) entry->expr); + + if (IsA(expr, Var) && + ((Var *) expr)->varno == varno && + ((Var *) expr)->varattno == attno) + return entry->resno; + } + return InvalidAttrNumber; +} + +static void +pgcolumnar_join_fold_reset_table(PgColumnarAggScanState *state) +{ + state->joinFoldOccupied = NULL; + state->joinFoldHashes = NULL; + state->joinFoldKeys = NULL; + state->joinFoldNslots = 0; + state->joinFoldNkeys = 0; + state->joinFoldKeysReady = false; +} + +static bool +pgcolumnar_join_fold_eq(PgColumnarAggScanState *state, Datum a, Datum b) +{ + return DatumGetBool(FunctionCall2Coll(&state->joinEqFn, + state->joinCollation, a, b)); +} + +static uint32 +pgcolumnar_join_fold_hash(PgColumnarAggScanState *state, Datum value) +{ + return DatumGetUInt32(FunctionCall1Coll(&state->joinHashFn, + state->joinCollation, value)); +} + +static void pgcolumnar_join_fold_insert(PgColumnarAggScanState *state, + Datum value); + +static void +pgcolumnar_join_fold_grow(PgColumnarAggScanState *state) +{ + uint32 oldSlots = state->joinFoldNslots; + char *oldOcc = state->joinFoldOccupied; + Datum *oldKeys = state->joinFoldKeys; + uint32 i; + + state->joinFoldNslots = (oldSlots == 0) ? 16 : oldSlots * 2; + state->joinFoldOccupied = (char *) palloc0(state->joinFoldNslots); + state->joinFoldHashes = (uint32 *) palloc0(sizeof(uint32) * + state->joinFoldNslots); + state->joinFoldKeys = (Datum *) palloc0(sizeof(Datum) * + state->joinFoldNslots); + state->joinFoldNkeys = 0; + for (i = 0; i < oldSlots; i++) + { + if (oldOcc[i]) + pgcolumnar_join_fold_insert(state, oldKeys[i]); + } +} + +static void +pgcolumnar_join_fold_insert(PgColumnarAggScanState *state, Datum value) +{ + uint32 h; + uint32 mask; + uint32 i; + Datum copied; + + if (state->joinFoldNslots == 0 || + state->joinFoldNkeys * 4 > state->joinFoldNslots * 3) + pgcolumnar_join_fold_grow(state); + copied = datumCopy(value, state->joinTypbyval, state->joinTyplen); + h = pgcolumnar_join_fold_hash(state, copied); + mask = state->joinFoldNslots - 1; + i = h & mask; + for (;;) + { + if (!state->joinFoldOccupied[i]) + { + state->joinFoldOccupied[i] = 1; + state->joinFoldHashes[i] = h; + state->joinFoldKeys[i] = copied; + state->joinFoldNkeys++; + return; + } + if (state->joinFoldHashes[i] == h && + pgcolumnar_join_fold_eq(state, state->joinFoldKeys[i], copied)) + return; + i = (i + 1) & mask; + } +} + +static bool +pgcolumnar_join_fold_lookup(PgColumnarAggScanState *state, Datum value) +{ + uint32 h; + uint32 mask; + uint32 i; + uint32 start; + + if (state->joinFoldNkeys == 0) + return false; + h = pgcolumnar_join_fold_hash(state, value); + mask = state->joinFoldNslots - 1; + i = h & mask; + start = i; + do + { + if (!state->joinFoldOccupied[i]) + return false; + if (state->joinFoldHashes[i] == h && + pgcolumnar_join_fold_eq(state, state->joinFoldKeys[i], value)) + return true; + i = (i + 1) & mask; + } while (i != start); + return false; +} + +static void +pgcolumnar_join_fold_drain(PgColumnarAggScanState *state) +{ + MemoryContext old; + TupleTableSlot *slot; + + if (state->joinBuildState == NULL || state->joinFoldKeysReady) + return; + old = MemoryContextSwitchTo(state->joinFoldContext); + pgcolumnar_join_fold_reset_table(state); + for (;;) + { + bool isnull; + Datum value; + + slot = ExecProcNode(state->joinBuildState); + if (TupIsNull(slot)) + break; + value = slot_getattr(slot, state->joinBuildResno, &isnull); + if (!isnull) + pgcolumnar_join_fold_insert(state, value); + CHECK_FOR_INTERRUPTS(); + } + MemoryContextSwitchTo(old); + state->joinFoldKeysReady = true; +} + /* * PgColumnarCreateUpperPaths * create_upper_paths_hook: for a plain SELECT agg(col) FROM pgcolumnar_table @@ -912,6 +1347,12 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage, Path *cheapest; CustomPath *cpath; bool parallelAdded = false; + Index factRti = 0; + RelOptInfo *factRel = NULL; + Path *dimPath = NULL; + AttrNumber joinFactAttno = 0; + Index joinBuildVarno = 0; + AttrNumber joinBuildAttno = 0; if (prev_create_upper_paths_hook) prev_create_upper_paths_hook(root, stage, input_rel, output_rel, extra); @@ -947,15 +1388,35 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage, /* plain, ungrouped aggregation only (spec 9) */ - /* a single columnar base relation with no joins */ - if (input_rel->reloptkind != RELOPT_BASEREL) - return; - if (bms_membership(input_rel->relids) != BMS_SINGLETON) + /* + * A single columnar base relation, or a unique-key inner Hash Join whose + * outer is that relation (#752). A join that would multiply fact rows is + * refused and the ordinary Agg runs. + */ + if (input_rel->reloptkind == RELOPT_BASEREL) + { + if (bms_membership(input_rel->relids) != BMS_SINGLETON) + return; + if (input_rel->relid == 0 || + input_rel->relid >= (Index) root->simple_rel_array_size) + return; + factRti = input_rel->relid; + factRel = input_rel; + } + else if (input_rel->reloptkind == RELOPT_JOINREL) + { + if (!pgcolumnar_enable_ungrouped_vector_agg) + return; + if (!pgcolumnar_join_fold_try(root, input_rel, &factRti, &factRel, + &dimPath, &joinFactAttno, + &joinBuildVarno, &joinBuildAttno)) + return; + } + else return; - if (input_rel->relid == 0 || - input_rel->relid >= (Index) root->simple_rel_array_size) + if (factRti == 0 || factRti >= (Index) root->simple_rel_array_size) return; - rte = root->simple_rte_array[input_rel->relid]; + rte = root->simple_rte_array[factRti]; if (rte == NULL || rte->rtekind != RTE_RELATION || rte->relkind != RELKIND_RELATION) return; @@ -1046,7 +1507,7 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage, i = 0; foreach(lc, aggList) { - if (!pgcolumnar_classify_aggref((Aggref *) lfirst(lc), (int) input_rel->relid, + if (!pgcolumnar_classify_aggref((Aggref *) lfirst(lc), (int) factRti, true, false, &specs[i])) return; i++; @@ -1059,12 +1520,14 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage, * zone-map answerable. A filter, or a sum/avg over int8/float/numeric, needs * the scan-fold path instead (#289). */ - quals = extract_actual_clauses(input_rel->baserestrictinfo, false); + quals = extract_actual_clauses(factRel->baserestrictinfo, false); needsScan = (quals != NIL); for (i = 0; i < naggs; i++) if (!pgcolumnar_agg_metadata_answerable(specs[i].kind)) needsScan = true; + if (dimPath != NULL) + needsScan = true; if (needsScan) { @@ -1134,7 +1597,7 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage, * apply it, so a false gate would wrongly return a row. Rare; fall back. * (Mirrors the grouped path.) */ - foreach(rc, input_rel->baserestrictinfo) + foreach(rc, factRel->baserestrictinfo) if (lfirst_node(RestrictInfo, rc)->pseudoconstant) return; @@ -1143,7 +1606,7 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage, * the projected data columns the recheck reads; fall back rather than * evaluate it against unset slot values. */ - pull_varattnos((Node *) quals, input_rel->relid, &whereAtts); + pull_varattnos((Node *) quals, factRti, &whereAtts); while ((m = bms_next_member(whereAtts, m)) >= 0) if (m + FirstLowInvalidHeapAttributeNumber <= 0) return; @@ -1155,14 +1618,14 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage, Relation rel = table_open(relid, AccessShareLock); TupleDesc tupdesc = RelationGetDescr(rel); - PgColumnarCountConvertibleQuals(quals, input_rel->relid, tupdesc, + PgColumnarCountConvertibleQuals(quals, factRti, tupdesc, &npreds, &allConvertible); table_close(rel, AccessShareLock); if (!allConvertible) return; } - cheapest = input_rel->cheapest_total_path; + cheapest = factRel->cheapest_total_path; if (cheapest == NULL) return; @@ -1224,7 +1687,7 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage, ListCell *pc; Cost cost; - foreach(pc, input_rel->pathlist) + foreach(pc, factRel->pathlist) { Path *p = (Path *) lfirst(pc); @@ -1237,6 +1700,8 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage, if (scanp == NULL) scanp = cheapest; cost = scanp->total_cost + cpu_tuple_cost; + if (dimPath != NULL) + cost += dimPath->total_cost; cpath->path.startup_cost = cost; cpath->path.total_cost = cost; } @@ -1318,15 +1783,26 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage, } cpath->path.pathkeys = NIL; cpath->flags = 0; - cpath->custom_paths = NIL; + cpath->custom_paths = (dimPath != NULL) ? list_make1(dimPath) : NIL; #if PG_VERSION_NUM >= 170000 cpath->custom_restrictinfo = NIL; #endif cpath->custom_private = - list_make3(makeInteger((int) input_rel->relid), + list_make3(makeInteger((int) factRti), copyObject(quals), makeConst(OIDOID, -1, InvalidOid, sizeof(Oid), ObjectIdGetDatum(relid), false, true)); + if (dimPath != NULL) + { + cpath->custom_private = lappend(cpath->custom_private, + makeInteger((int) joinFactAttno)); + cpath->custom_private = lappend(cpath->custom_private, + makeInteger((int) joinBuildVarno)); + cpath->custom_private = lappend(cpath->custom_private, + makeInteger((int) joinBuildAttno)); + cpath->custom_private = lappend(cpath->custom_private, + makeInteger(0)); + } cpath->methods = &pgcolumnar_agg_path_methods; /* @@ -1345,7 +1821,7 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage, * cheap Gather cost by #133, would wrongly out-cost the genuinely parallel * plan. Opt-in while it is proven and benchmarked. */ - if (needsScan && pgcolumnar_enable_parallel_vector_agg) + if (dimPath == NULL && needsScan && pgcolumnar_enable_parallel_vector_agg) { GroupPathExtraData *gpe = (GroupPathExtraData *) extra; bool parallelOk = (gpe != NULL && @@ -2226,6 +2702,15 @@ PgColumnarCreateAggScanState(CustomScan *cscan) state->scanrelid = (Index) intVal(linitial(cscan->custom_private)); state->quals = (List *) lsecond(cscan->custom_private); state->relid = DatumGetObjectId(((Const *) lthird(cscan->custom_private))->constvalue); + state->joinFactAttno = 0; + state->joinBuildResno = 0; + if (list_length(cscan->custom_private) >= 7) + { + state->joinFactAttno = + (AttrNumber) intVal(list_nth(cscan->custom_private, 3)); + state->joinBuildResno = + (AttrNumber) intVal(list_nth(cscan->custom_private, 6)); + } /* * A parallel partial node (#289 phase 5/6) is planned with @@ -2270,6 +2755,8 @@ PgColumnarCreateAggScanState(CustomScan *cscan) for (i = 0; i < naggs; i++) if (!pgcolumnar_agg_metadata_answerable(state->specs[i].kind)) state->scanFold = true; + if (state->joinFactAttno > 0) + state->scanFold = true; return (Node *) state; } @@ -2324,6 +2811,9 @@ PgColumnarBeginAggScan(CustomScanState *node, EState *estate, int eflags) if (state->specs[a].attidx >= 0) state->projected = bms_add_member(state->projected, state->specs[a].attidx); + if (state->joinFactAttno > 0) + state->projected = bms_add_member(state->projected, + state->joinFactAttno - 1); if (state->projected == NULL) state->projected = bms_make_singleton(0); @@ -2377,6 +2867,30 @@ PgColumnarBeginAggScan(CustomScanState *node, EState *estate, int eflags) state->nscankeys = PgColumnarCountScanKeys(state->quals, state->scanrelid, tupdesc); + if (state->joinFactAttno > 0) + { + CustomScan *cscan = (CustomScan *) node->ss.ps.plan; + Form_pg_attribute att; + TypeCacheEntry *tce; + + if (list_length(cscan->custom_plans) != 1) + elog(ERROR, "pgcolumnar join fold expected one dimension plan"); + att = TupleDescAttr(tupdesc, state->joinFactAttno - 1); + tce = lookup_type_cache(att->atttypid, + TYPECACHE_EQ_OPR_FINFO | + TYPECACHE_HASH_PROC_FINFO); + fmgr_info_copy(&state->joinEqFn, &tce->eq_opr_finfo, + estate->es_query_cxt); + fmgr_info_copy(&state->joinHashFn, &tce->hash_proc_finfo, + estate->es_query_cxt); + state->joinTyplen = att->attlen; + state->joinTypbyval = att->attbyval; + state->joinCollation = att->attcollation; + state->joinBuildState = ExecInitNode((Plan *) linitial(cscan->custom_plans), + estate, eflags); + node->custom_ps = list_make1(state->joinBuildState); + } + if (eflags & EXEC_FLAG_EXPLAIN_ONLY) { table_close(rel, AccessShareLock); @@ -2395,6 +2909,15 @@ PgColumnarBeginAggScan(CustomScanState *node, EState *estate, int eflags) PgColumnarCheckNativeFormatVersion(PgColumnarStorageId(rel), RelationGetRelationName(rel)); + if (state->joinFactAttno > 0) + { + state->joinFoldContext = + AllocSetContextCreate(estate->es_query_cxt, + "columnar join fold keys", + ALLOCSET_SMALL_SIZES); + pgcolumnar_join_fold_drain(state); + } + /* finish setting up min/max comparison info now that we have the tupdesc */ for (a = 0; a < state->naggs; a++) { @@ -3574,6 +4097,9 @@ pgcolumnar_native_batch_fold(PgColumnarAggScanState *state, Relation rel, int64 survRows = 0; /* rows that passed it */ bool deferOn = false; + if (state->joinFactAttno > 0) + return false; + if (!pgcolumnar_batch_shape_eligible(state, tupdesc, &keys, &nkeys)) return false; @@ -4023,6 +4549,14 @@ pgcolumnar_native_scan_agg(PgColumnarAggScanState *state, continue; } + if (state->joinFactAttno > 0) + { + int jk = state->joinFactAttno - 1; + + if (nulls[jk] || !pgcolumnar_join_fold_lookup(state, values[jk])) + continue; + } + for (a = 0; a < state->naggs; a++) { PgColumnarAggSpec *spec = &state->specs[a]; @@ -4176,6 +4710,16 @@ PgColumnarEndAggScan(CustomScanState *node) if (state->baseSlot != NULL) ExecDropSingleTupleTableSlot(state->baseSlot); state->baseSlot = NULL; + if (state->joinBuildState != NULL) + { + ExecEndNode(state->joinBuildState); + state->joinBuildState = NULL; + } + if (state->joinFoldContext != NULL) + { + MemoryContextDelete(state->joinFoldContext); + state->joinFoldContext = NULL; + } /* the reader is ended inside PgColumnarExecAggScan; the memory contexts are * children of es_query_cxt and freed with it */ } @@ -4212,6 +4756,15 @@ PgColumnarReScanAggScan(CustomScanState *node) * nsumSet true would make the next scan add to a dangling pointer. */ pgcolumnar_agg_specs_reset(state); + + if (state->joinBuildState != NULL) + { + ExecReScan(state->joinBuildState); + if (state->joinFoldContext != NULL) + MemoryContextReset(state->joinFoldContext); + pgcolumnar_join_fold_reset_table(state); + pgcolumnar_join_fold_drain(state); + } } static void @@ -4221,6 +4774,8 @@ PgColumnarExplainAggScan(CustomScanState *node, List *ancestors, ExplainState *e ExplainPropertyInteger("Columnar Vectorized Aggregates", NULL, state->naggs, es); + if (state->joinFactAttno > 0) + ExplainPropertyText("Columnar Join Fold", "yes", es); PgColumnarExplainPushedDown(state->nscankeys, es); PgColumnarExplainVectorPredicates(state->npreds, es); if (state->scanFold) diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index d0e4769f..72a249ec 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -1153,3 +1153,11 @@ native_join_runtime_filter native_join_runtime_filter scattered bloom rejects mo native_join_runtime_filter native_join_runtime_filter scattered interval cannot drop groups never - native_join_runtime_filter native_join_runtime_filter scattered plan has coordinator never - native_join_runtime_filter native_join_runtime_filter scattered still reads every group never - +native_join_vector_agg native_join_vector_agg LEFT join answer equals heap never - +native_join_vector_agg native_join_vector_agg LEFT join refuses the join fold never - +native_join_vector_agg native_join_vector_agg duplicate dim keys answer equals heap never - +native_join_vector_agg native_join_vector_agg duplicate dim keys refuse the join fold never - +native_join_vector_agg native_join_vector_agg unique join fold answer equals GUC off never - +native_join_vector_agg native_join_vector_agg unique join fold answer equals heap never - +native_join_vector_agg native_join_vector_agg unique join uses core Agg when GUC off never - +native_join_vector_agg native_join_vector_agg unique join uses vectorized agg when GUC on 2026-09-12 drop JOINREL fold diff --git a/test/check_ledger_budget.txt b/test/check_ledger_budget.txt index 00efd917..bcf0c3dc 100644 --- a/test/check_ledger_budget.txt +++ b/test/check_ledger_budget.txt @@ -45,4 +45,4 @@ suites_not_covered 249 # the number of attacked checks -- and it overcounts from the first moment this # ledger does the job it exists for. The gate prints both quantities side by side # (`rows=N | never observed red=M`) because they are different questions. -checks_never_observed_red 1155 +checks_never_observed_red 1162 diff --git a/test/native_join_vector_agg.sh b/test/native_join_vector_agg.sh new file mode 100644 index 00000000..c4c86dbd --- /dev/null +++ b/test/native_join_vector_agg.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Ungrouped vectorized aggregate over a unique-key inner join (#752). +# +# The fold is worth 4.1x on a bare scan. create_upper_paths_hook drops it the +# moment the input is a joinrel (RELOPT_BASEREL). A star-schema inner join onto +# a UNIQUE dimension is a filter of the fact table, so the fold can survive. +# A duplicate-key dimension is not that filter: the path must refuse and core +# Agg over the join remains correct. +set -uo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +pgc_setup "${1:-/usr/lib/postgresql/18/bin/pg_config}" + +GUC=pgcolumnar.enable_ungrouped_vector_agg +NOPAR="SET max_parallel_workers_per_gather=0" +FORCEH="SET enable_nestloop=off; SET enable_mergejoin=off" + +pc() { + env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \ + -d "$PGC_DB" -At -v ON_ERROR_STOP=1 -c "$NOPAR" -c "$FORCEH" -c "$1" 2>&1 +} + +q "$(cat <<'SQL' +CREATE TABLE dim_u(k int PRIMARY KEY); +INSERT INTO dim_u SELECT g FROM generate_series(1,25) g; +CREATE TABLE fact_u(k int, m float8) USING pgcolumnar; +SELECT pgcolumnar.set_options($t$fact_u$t$, stripe_row_limit => 1000); +INSERT INTO fact_u SELECT 1 + (g % 50), (g % 17)::float8 + FROM generate_series(1,5000) g; +CREATE TABLE heap_u (k int, m float8) USING heap; +INSERT INTO heap_u SELECT * FROM fact_u; +ANALYZE dim_u; +ANALYZE fact_u; +SQL +)" >/dev/null + +SQLU="SELECT count(*), coalesce(sum(fact_u.m)::text,'z') FROM fact_u JOIN dim_u ON fact_u.k = dim_u.k" + +plan_on="$(pc "SET $GUC=on; EXPLAIN (COSTS OFF) $SQLU")" +plan_off="$(pc "SET $GUC=off; EXPLAIN (COSTS OFF) $SQLU")" +check "unique join uses vectorized agg when GUC on" \ + "$(grep -c 'Columnar Vectorized Aggregates' <<<"$plan_on")" 1 +check "unique join uses core Agg when GUC off" \ + "$(grep -c 'Columnar Vectorized Aggregates' <<<"$plan_off")" 0 +check "unique join fold answer equals GUC off" \ + "$(pc "SET $GUC=on; $SQLU" | tail -1)" \ + "$(pc "SET $GUC=off; $SQLU" | tail -1)" +check "unique join fold answer equals heap" \ + "$(pc "SET $GUC=on; $SQLU" | tail -1)" \ + "$(q "SELECT count(*), coalesce(sum(heap_u.m)::text,'z') FROM heap_u JOIN dim_u ON heap_u.k = dim_u.k")" + +q "$(cat <<'SQL' +CREATE TABLE dim_d(k int); +INSERT INTO dim_d SELECT g FROM generate_series(1,50) g; +INSERT INTO dim_d SELECT g FROM generate_series(1,50) g; +CREATE TABLE fact_d(k int, m float8) USING pgcolumnar; +INSERT INTO fact_d SELECT 1 + (g % 50), 1::float8 FROM generate_series(1,100) g; +CREATE TABLE heap_d (k int, m float8) USING heap; +INSERT INTO heap_d SELECT * FROM fact_d; +ANALYZE dim_d; +ANALYZE fact_d; +SQL +)" >/dev/null + +SQLD="SELECT count(*), coalesce(sum(fact_d.m)::text,'z') FROM fact_d JOIN dim_d ON fact_d.k = dim_d.k" +plan_dup="$(pc "SET $GUC=on; EXPLAIN (COSTS OFF) $SQLD")" +check "duplicate dim keys refuse the join fold" \ + "$(grep -c 'Columnar Vectorized Aggregates' <<<"$plan_dup")" 0 +check "duplicate dim keys answer equals heap" \ + "$(pc "SET $GUC=on; $SQLD" | tail -1)" \ + "$(q "SELECT count(*), coalesce(sum(heap_d.m)::text,'z') FROM heap_d JOIN dim_d ON heap_d.k = dim_d.k")" + +q "$(cat <<'SQL' +CREATE TABLE dim_l(k int PRIMARY KEY); +INSERT INTO dim_l VALUES (1); +CREATE TABLE fact_l(k int, m float8) USING pgcolumnar; +INSERT INTO fact_l VALUES (1, 1.0), (2, 2.0); +CREATE TABLE heap_l (k int, m float8) USING heap; +INSERT INTO heap_l SELECT * FROM fact_l; +ANALYZE dim_l; +ANALYZE fact_l; +SQL +)" >/dev/null + +SQLL="SELECT count(*), coalesce(sum(dim_l.k)::text,'z') FROM fact_l LEFT JOIN dim_l ON fact_l.k = dim_l.k" +plan_left="$(pc "SET $GUC=on; EXPLAIN (COSTS OFF) $SQLL")" +check "LEFT join refuses the join fold" \ + "$(grep -c 'Columnar Vectorized Aggregates' <<<"$plan_left")" 0 +check "LEFT join answer equals heap" \ + "$(pc "SET $GUC=on; $SQLL" | tail -1)" \ + "$(q "SELECT count(*), coalesce(sum(dim_l.k)::text,'z') FROM heap_l LEFT JOIN dim_l ON heap_l.k = dim_l.k")" + +pgc_summary diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index 5e955aa8..3942fb6d 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -73,6 +73,7 @@ behaviour, the source of that number is named. - [25. test_join_runtime_filter.py: serial join runtime filter](#25-test_join_runtime_filterpy-serial-join-runtime-filter) - [26. test_check_records.py: every counted assertion is a record](#26-test_check_recordspy-every-counted-assertion-is-a-record) - [27. test_skip_loop_arms.py: a skipped arm records under its own name](#27-test_skip_loop_armspy-a-skipped-arm-records-under-its-own-name) +- [28. test_join_vector_agg.py: ungrouped fold over a unique-key join](#28-test_join_vector_aggpy-ungrouped-fold-over-a-unique-key-join) ## 1. How to read a test in here @@ -2698,3 +2699,27 @@ cheapest to satisfy wrongly: a classifier that filed **everything** as `armless` it perfectly. It is load-bearing only because the per-bucket tests assert that a known site lands in the right bucket; the identity then says nothing else escaped. Both halves or neither. + +## 28. test_join_vector_agg.py: ungrouped fold over a unique-key join + +Pytest twin of `test/native_join_vector_agg.sh`. The two files are independent: +each builds its own fixtures and expected values. They share only the public +EXPLAIN name `Columnar Vectorized Aggregates` and the SQL answers. + +### `test_unique_join_keeps_the_ungrouped_fold` + +A unique-key inner join is a filter of the fact table. With the ungrouped GUC +on, EXPLAIN shows `Columnar Vectorized Aggregates`. The GUC-off plan is core +Agg. The answer matches GUC-off and a heap twin. The dimension holds a subset +of the fact keys, so a fold that skipped membership would disagree with heap. + +### `test_duplicate_dim_keys_refuse_the_join_fold` + +Duplicate dimension keys would multiply fact rows. EXPLAIN has no vectorized +agg node. The answer still matches a heap twin of the same join. + +### `test_left_join_refuses_the_join_fold` + +A LEFT join is not a fact-table filter. The target list names a dimension +column so the planner cannot drop the join. EXPLAIN has no vectorized agg +node. The answer matches a heap twin, including unmatched fact rows. diff --git a/test/pytest/test_join_vector_agg.py b/test/pytest/test_join_vector_agg.py new file mode 100644 index 00000000..19d54f72 --- /dev/null +++ b/test/pytest/test_join_vector_agg.py @@ -0,0 +1,140 @@ +"""Ungrouped vectorized aggregate over a unique-key inner join (#752). + +Public seams: EXPLAIN marker ``Columnar Vectorized Aggregates`` and the SQL +answer. Independently of native_join_vector_agg.sh, this session builds its +own fact, unique dimension, duplicate dimension, and heap twins. +""" + + +def _exec(cur, sql): + cur.execute(sql) + if cur.description is None: + return None + return cur.fetchall() + + +def _plan_and_value(cur, sql): + cur.execute("SET max_parallel_workers_per_gather=0") + cur.execute("SET enable_nestloop=off") + cur.execute("SET enable_mergejoin=off") + cur.execute("EXPLAIN (COSTS OFF) " + sql) + plan = "\n".join(r[0] for r in cur.fetchall()) + cur.execute(sql) + return plan, cur.fetchone() + + +def test_unique_join_keeps_the_ungrouped_fold(pgc_conn, expect): + """A unique-key inner join is a fact-table filter, so the fold can run. + + Public seam: EXPLAIN and the SQL answer. The GUC-off plan is core Agg. + """ + with pgc_conn.cursor() as c: + _exec( + c, + """ + CREATE TABLE dim_u(k int PRIMARY KEY); + INSERT INTO dim_u SELECT g FROM generate_series(1,25) g; + CREATE TABLE fact_u(k int, m float8) USING pgcolumnar; + SELECT pgcolumnar.set_options($t$fact_u$t$, stripe_row_limit => 1000); + INSERT INTO fact_u SELECT 1 + (g % 50), (g % 17)::float8 + FROM generate_series(1,5000) g; + CREATE TABLE heap_u (k int, m float8) USING heap; + INSERT INTO heap_u SELECT * FROM fact_u; + ANALYZE dim_u; + ANALYZE fact_u + """, + ) + sql = ( + "SELECT count(*), coalesce(sum(fact_u.m)::text,'z') " + "FROM fact_u JOIN dim_u ON fact_u.k = dim_u.k" + ) + c.execute("SET pgcolumnar.enable_ungrouped_vector_agg=on") + plan_on, on = _plan_and_value(c, sql) + c.execute("SET pgcolumnar.enable_ungrouped_vector_agg=off") + plan_off, off = _plan_and_value(c, sql) + c.execute("SET pgcolumnar.enable_ungrouped_vector_agg=on") + c.execute( + "SELECT count(*), coalesce(sum(heap_u.m)::text,'z') " + "FROM heap_u JOIN dim_u ON heap_u.k = dim_u.k" + ) + heap = c.fetchone() + expect.num(plan_on.count("Columnar Vectorized Aggregates"), 1, + "unique join uses vectorized agg when GUC on") + expect.num(plan_off.count("Columnar Vectorized Aggregates"), 0, + "unique join uses core Agg when GUC off") + expect.rows([on], [off], "unique join fold answer equals GUC off") + expect.rows([on], [heap], "unique join fold answer equals heap") + + +def test_duplicate_dim_keys_refuse_the_join_fold(pgc_conn, expect): + """Duplicate dimension keys would multiply fact rows. The fold must refuse. + + Public seam: EXPLAIN has no vectorized agg node, and the answer still + matches a heap twin of the same join. + """ + with pgc_conn.cursor() as c: + _exec( + c, + """ + CREATE TABLE dim_d(k int); + INSERT INTO dim_d SELECT g FROM generate_series(1,50) g; + INSERT INTO dim_d SELECT g FROM generate_series(1,50) g; + CREATE TABLE fact_d(k int, m float8) USING pgcolumnar; + INSERT INTO fact_d SELECT 1 + (g % 50), 1::float8 + FROM generate_series(1,100) g; + CREATE TABLE heap_d (k int, m float8) USING heap; + INSERT INTO heap_d SELECT * FROM fact_d; + ANALYZE dim_d; + ANALYZE fact_d + """, + ) + sql = ( + "SELECT count(*), coalesce(sum(fact_d.m)::text,'z') " + "FROM fact_d JOIN dim_d ON fact_d.k = dim_d.k" + ) + c.execute("SET pgcolumnar.enable_ungrouped_vector_agg=on") + plan, got = _plan_and_value(c, sql) + c.execute( + "SELECT count(*), coalesce(sum(heap_d.m)::text,'z') " + "FROM heap_d JOIN dim_d ON heap_d.k = dim_d.k" + ) + heap = c.fetchone() + expect.num(plan.count("Columnar Vectorized Aggregates"), 0, + "duplicate dim keys refuse the join fold") + expect.rows([got], [heap], "duplicate dim keys answer equals heap") + + +def test_left_join_refuses_the_join_fold(pgc_conn, expect): + """A LEFT join is not a fact-table filter. Core Agg stays in charge.""" + with pgc_conn.cursor() as c: + _exec( + c, + """ + CREATE TABLE dim_l(k int PRIMARY KEY); + INSERT INTO dim_l VALUES (1); + CREATE TABLE fact_l(k int, m float8) USING pgcolumnar; + INSERT INTO fact_l VALUES (1, 1.0), (2, 2.0); + ANALYZE dim_l; + ANALYZE fact_l + """, + ) + sql = ( + "SELECT count(*), coalesce(sum(dim_l.k)::text,'z') " + "FROM fact_l LEFT JOIN dim_l ON fact_l.k = dim_l.k" + ) + c.execute("SET pgcolumnar.enable_ungrouped_vector_agg=on") + plan, got = _plan_and_value(c, sql) + c.execute( + """ + CREATE TABLE heap_l (k int, m float8) USING heap; + INSERT INTO heap_l SELECT * FROM fact_l + """ + ) + c.execute( + "SELECT count(*), coalesce(sum(dim_l.k)::text,'z') " + "FROM heap_l LEFT JOIN dim_l ON heap_l.k = dim_l.k" + ) + heap = c.fetchone() + expect.num(plan.count("Columnar Vectorized Aggregates"), 0, + "LEFT join refuses the join fold") + expect.rows([got], [heap], "LEFT join answer equals heap") diff --git a/test/run_all_versions.sh b/test/run_all_versions.sh index cc471633..cd033c7c 100755 --- a/test/run_all_versions.sh +++ b/test/run_all_versions.sh @@ -164,6 +164,7 @@ SUITES=( native_index_projection native_ios native_join_runtime_filter + native_join_vector_agg native_late_materialization native_lazy_slot native_metadata_flush From be0ef93c546775d5bcc4140c8f9d2a259f3749e1 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Sat, 12 Sep 2026 11:27:29 -0600 Subject: [PATCH 2/3] test: make native_join_vector_agg.sh executable harness_selftest refused the tree: FAIL every script that declares an interpreter is executable: got [[1: test/native_join_vector_agg.sh]] want [[]] The file has a shebang and mode 100644. Every sibling suite is 100755. NOT INTRODUCED BY THE MERGE COMMIT BELOW. The blob and the mode are identical on the pre-merge head 48531f36 -- same hash, same 100644 -- so this arrived with the branch. It went unseen because this PR was DIRTY and **no CI run ever existed for it**: `statusCheckRollup` was empty, which reads as "0 pending, 0 failing" and is not the same thing as passing. Rebasing it did not break it; rebasing it was what first let anything look. The suite itself passes (`native_join_vector_agg=PASS` in the same run). Only the mode guard failed, and that guard is the reason a suite nobody can execute cannot reach main. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw --- test/native_join_vector_agg.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 test/native_join_vector_agg.sh diff --git a/test/native_join_vector_agg.sh b/test/native_join_vector_agg.sh old mode 100644 new mode 100755 From fbc1e5d2d03cbf620193bd1419823d93e282a892 Mon Sep 17 00:00:00 2001 From: "Joshua (D) Drake" <136637981+ChronicallyJD@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:22:52 +0000 Subject: [PATCH 3/3] Refuse the join fold unless the hash clause is the only join clause. An extra Join Filter was dropped, so the fold returned the equi-join sum instead of the filtered one. --- CHANGELOG.md | 5 +- src/columnar_vector.c | 7 +++ test/check_ledger.tsv | 5 ++ test/native_join_vector_agg.sh | 45 +++++++++++++++++ test/pytest/TESTS.md | 11 +++++ test/pytest/test_join_vector_agg.py | 77 +++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d7d6fc..82f8a812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,11 +22,12 @@ true until the next version shipped. The fold used to require a single base relation, so a star-schema join dropped it. A unique dimension is a filter of the fact table, so the fold can keep running. - Duplicate-key dimensions, LEFT joins, and grouped aggregation over a join still use core Agg. + Duplicate-key dimensions, LEFT joins, extra Join Filters, and grouped aggregation over a join still use core Agg. + A Join Filter besides the hash clause is not a membership test, so the fold refuses it. `pgcolumnar.enable_ungrouped_vector_agg` stays off by default. checks_never_observed_red 1155 -> 1162 - covered native_join_vector_agg, 8 checks, one last-red 2026-09-12 + covered native_join_vector_agg, 13 checks, last-red 2026-09-12 - The mutation ledger covers a third suite: `differential`, 204 checks (#752). diff --git a/src/columnar_vector.c b/src/columnar_vector.c index 7a5f9f36..4baeb346 100644 --- a/src/columnar_vector.c +++ b/src/columnar_vector.c @@ -1137,6 +1137,13 @@ pgcolumnar_join_fold_try(PlannerInfo *root, RelOptInfo *joinrel, hashPath = pgcolumnar_join_fold_hashpath(joinrel); if (hashPath == NULL) return false; + /* + * A Join Filter besides the hash clause is not a membership test. The + * fold would ignore it and over-count. Three-way joins are already + * refused above: more than two relids. + */ + if (list_length(hashPath->jpath.joinrestrictinfo) != 1) + return false; outerPath = hashPath->jpath.outerjoinpath; innerPath = hashPath->jpath.innerjoinpath; if (!pgcolumnar_join_fold_base_scan(outerPath)) diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index e142f8ad..cdcf7ff1 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -1159,6 +1159,11 @@ native_join_vector_agg native_join_vector_agg LEFT join answer equals heap never native_join_vector_agg native_join_vector_agg LEFT join refuses the join fold never - native_join_vector_agg native_join_vector_agg duplicate dim keys answer equals heap never - native_join_vector_agg native_join_vector_agg duplicate dim keys refuse the join fold never - +native_join_vector_agg native_join_vector_agg extra join filter answer equals GUC off 2026-09-12 - +native_join_vector_agg native_join_vector_agg extra join filter answer equals heap 2026-09-12 - +native_join_vector_agg native_join_vector_agg extra join filter refuses the join fold 2026-09-12 - +native_join_vector_agg native_join_vector_agg inequality join filter answer equals heap 2026-09-12 - +native_join_vector_agg native_join_vector_agg inequality join filter refuses the join fold 2026-09-12 - native_join_vector_agg native_join_vector_agg unique join fold answer equals GUC off never - native_join_vector_agg native_join_vector_agg unique join fold answer equals heap never - native_join_vector_agg native_join_vector_agg unique join uses core Agg when GUC off never - diff --git a/test/native_join_vector_agg.sh b/test/native_join_vector_agg.sh index c4c86dbd..2824a77e 100755 --- a/test/native_join_vector_agg.sh +++ b/test/native_join_vector_agg.sh @@ -89,4 +89,49 @@ check "LEFT join answer equals heap" \ "$(pc "SET $GUC=on; $SQLL" | tail -1)" \ "$(q "SELECT count(*), coalesce(sum(dim_l.k)::text,'z') FROM heap_l LEFT JOIN dim_l ON heap_l.k = dim_l.k")" +# A Join Filter besides the hash clause is not a membership test. The fold that +# only drains dim keys would ignore f.v > d.t and over-count. +q "$(cat <<'SQL' +CREATE TABLE dim_xf(k int PRIMARY KEY, t int); +INSERT INTO dim_xf SELECT g, 20 FROM generate_series(1,40) g; +CREATE TABLE fact_xf(k int, v int) USING pgcolumnar; +INSERT INTO fact_xf SELECT 1 + (g % 40), g % 40 FROM generate_series(1,2000) g; +CREATE TABLE heap_xf (k int, v int) USING heap; +INSERT INTO heap_xf SELECT * FROM fact_xf; +ANALYZE dim_xf; +ANALYZE fact_xf; +SQL +)" >/dev/null + +SQLXF="SELECT coalesce(sum(fact_xf.v)::text,'z') FROM fact_xf JOIN dim_xf ON fact_xf.k = dim_xf.k AND fact_xf.v > dim_xf.t" +plan_xf="$(pc "SET $GUC=on; EXPLAIN (COSTS OFF) $SQLXF")" +check "extra join filter refuses the join fold" \ + "$(grep -c 'Columnar Vectorized Aggregates' <<<"$plan_xf")" 0 +check "extra join filter answer equals GUC off" \ + "$(pc "SET $GUC=on; $SQLXF" | tail -1)" \ + "$(pc "SET $GUC=off; $SQLXF" | tail -1)" +check "extra join filter answer equals heap" \ + "$(pc "SET $GUC=on; $SQLXF" | tail -1)" \ + "$(q "SELECT coalesce(sum(heap_xf.v)::text,'z') FROM heap_xf JOIN dim_xf ON heap_xf.k = dim_xf.k AND heap_xf.v > dim_xf.t")" + +q "$(cat <<'SQL' +CREATE TABLE dim_xn(k int PRIMARY KEY, t int); +INSERT INTO dim_xn SELECT g, 7 FROM generate_series(1,12) g; +CREATE TABLE fact_xn(k int, v int) USING pgcolumnar; +INSERT INTO fact_xn SELECT 1 + (g % 12), g % 9 FROM generate_series(1,360) g; +CREATE TABLE heap_xn (k int, v int) USING heap; +INSERT INTO heap_xn SELECT * FROM fact_xn; +ANALYZE dim_xn; +ANALYZE fact_xn; +SQL +)" >/dev/null + +SQLXN="SELECT coalesce(sum(fact_xn.v)::text,'z') FROM fact_xn JOIN dim_xn ON fact_xn.k = dim_xn.k AND fact_xn.v <> dim_xn.t" +plan_xn="$(pc "SET $GUC=on; EXPLAIN (COSTS OFF) $SQLXN")" +check "inequality join filter refuses the join fold" \ + "$(grep -c 'Columnar Vectorized Aggregates' <<<"$plan_xn")" 0 +check "inequality join filter answer equals heap" \ + "$(pc "SET $GUC=on; $SQLXN" | tail -1)" \ + "$(q "SELECT coalesce(sum(heap_xn.v)::text,'z') FROM heap_xn JOIN dim_xn ON heap_xn.k = dim_xn.k AND heap_xn.v <> dim_xn.t")" + pgc_summary diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index b73b7ff7..c217f70a 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -2764,3 +2764,14 @@ agg node. The answer still matches a heap twin of the same join. A LEFT join is not a fact-table filter. The target list names a dimension column so the planner cannot drop the join. EXPLAIN has no vectorized agg node. The answer matches a heap twin, including unmatched fact rows. + +### `test_extra_join_filter_refuses_the_fold` + +A Join Filter besides the hash clause is not a membership test. EXPLAIN has +no vectorized agg node. The sum matches GUC-off and a heap twin of the same +join. + +### `test_inequality_join_filter_refuses_the_fold` + +A non-equi join clause is the same kind of extra Join Filter. EXPLAIN has no +vectorized agg node. The sum matches a heap twin. diff --git a/test/pytest/test_join_vector_agg.py b/test/pytest/test_join_vector_agg.py index 19d54f72..ccdd8c1d 100644 --- a/test/pytest/test_join_vector_agg.py +++ b/test/pytest/test_join_vector_agg.py @@ -138,3 +138,80 @@ def test_left_join_refuses_the_join_fold(pgc_conn, expect): expect.num(plan.count("Columnar Vectorized Aggregates"), 0, "LEFT join refuses the join fold") expect.rows([got], [heap], "LEFT join answer equals heap") + + +def test_extra_join_filter_refuses_the_fold(pgc_conn, expect): + """A Join Filter besides the hash clause is not a fact-table filter. + + Public seam: EXPLAIN has no vectorized agg node, and the sum matches a + heap twin. Independently of native_join_vector_agg.sh. + """ + with pgc_conn.cursor() as c: + _exec( + c, + """ + CREATE TABLE dim_jf(k int PRIMARY KEY, t int); + INSERT INTO dim_jf SELECT g, 12 FROM generate_series(1,30) g; + CREATE TABLE fact_jf(k int, v int) USING pgcolumnar; + INSERT INTO fact_jf SELECT 1 + (g % 30), g % 30 + FROM generate_series(1,1200) g; + CREATE TABLE heap_jf (k int, v int) USING heap; + INSERT INTO heap_jf SELECT * FROM fact_jf; + ANALYZE dim_jf; + ANALYZE fact_jf + """, + ) + sql = ( + "SELECT coalesce(sum(fact_jf.v)::text,'z') " + "FROM fact_jf JOIN dim_jf " + "ON fact_jf.k = dim_jf.k AND fact_jf.v > dim_jf.t" + ) + c.execute("SET pgcolumnar.enable_ungrouped_vector_agg=on") + plan_on, on = _plan_and_value(c, sql) + c.execute("SET pgcolumnar.enable_ungrouped_vector_agg=off") + _plan_off, off = _plan_and_value(c, sql) + c.execute( + "SELECT coalesce(sum(heap_jf.v)::text,'z') " + "FROM heap_jf JOIN dim_jf " + "ON heap_jf.k = dim_jf.k AND heap_jf.v > dim_jf.t" + ) + heap = c.fetchone() + expect.num(plan_on.count("Columnar Vectorized Aggregates"), 0, + "extra join filter refuses the join fold") + expect.rows([on], [off], "extra join filter answer equals GUC off") + expect.rows([on], [heap], "extra join filter answer equals heap") + + +def test_inequality_join_filter_refuses_the_fold(pgc_conn, expect): + """A non-equi join clause is the same kind of extra Join Filter.""" + with pgc_conn.cursor() as c: + _exec( + c, + """ + CREATE TABLE dim_ne(k int PRIMARY KEY, t int); + INSERT INTO dim_ne SELECT g, 4 FROM generate_series(1,8) g; + CREATE TABLE fact_ne(k int, v int) USING pgcolumnar; + INSERT INTO fact_ne SELECT 1 + (g % 8), g % 6 + FROM generate_series(1,240) g; + CREATE TABLE heap_ne (k int, v int) USING heap; + INSERT INTO heap_ne SELECT * FROM fact_ne; + ANALYZE dim_ne; + ANALYZE fact_ne + """, + ) + sql = ( + "SELECT coalesce(sum(fact_ne.v)::text,'z') " + "FROM fact_ne JOIN dim_ne " + "ON fact_ne.k = dim_ne.k AND fact_ne.v <> dim_ne.t" + ) + c.execute("SET pgcolumnar.enable_ungrouped_vector_agg=on") + plan, got = _plan_and_value(c, sql) + c.execute( + "SELECT coalesce(sum(heap_ne.v)::text,'z') " + "FROM heap_ne JOIN dim_ne " + "ON heap_ne.k = dim_ne.k AND heap_ne.v <> dim_ne.t" + ) + heap = c.fetchone() + expect.num(plan.count("Columnar Vectorized Aggregates"), 0, + "inequality join filter refuses the join fold") + expect.rows([got], [heap], "inequality join filter answer equals heap")