| From: | zengxx <xiangxin_zeng(at)qq(dot)com> |
|---|---|
| To: | pgsql-hackers <pgsql-hackers(at)lists(dot)postgresql(dot)org> |
| Subject: | [PATCH 0/3] Improve UNION de-duplication |
| Date: | 2026-10-22 03:56:14 |
| Message-ID: | tencent_D56FD01446E8B3D6FA8234F55526DB543F0A@qq.com |
| Views: | Whole Thread | Raw Message | Download mbox | Resend email |
| Thread: | |
| Lists: | pgsql-hackers |
Hi all,
This is v1 of a patch series based on 3c6665b on master.
For low-cardinality inputs, the common hash-based plan for a basic UNION
does all de-duplication after Gather: the final HashAggregate reads every
row produced by every child. Since A UNION B is equivalent to
DISTINCT (A UNION ALL B), and set operations already share several DISTINCT
path shapes, comparing the two plans shows two missing optimizations:
1. UNION has no partial aggregation path below Gather. DISTINCT already
builds worker-local partial distinct paths.
2. A branch whose distinct output is known to be one tuple has no fast path.
DISTINCT can turn "SELECT DISTINCT id FROM tb WHERE id = 100" into a
one-row path; the equivalent UNION branch still scans all matching rows
before the parent removes duplicates.
The series addresses these points:
- Patch 1 extracts shared de-duplication path construction and adds a
memory-aware gate for the partial DISTINCT hash path.
- Patch 2 adds worker-local HashAggregate below Gather for UNION; the final
HashAggregate remains in the leader. The partial stage is asserted to be
UNION-only; INTERSECT/EXCEPT keep SetOp semantics.
- Patch 3 adds LIMIT 1 to a constant child of a non-ALL set operation,
including partial paths. It applies to all UNION children and both inputs
of INTERSECT/EXCEPT; ALL variants are excluded because duplicate counts
matter.
Design notes
------------
Partial UNION de-duplication. The path matches DISTINCT's partial hash
path: workers group by the UNION output columns, Gather combines partial
results, and the leader removes cross-worker duplicates. We did not reuse
create_final_distinct_paths() directly because UNION inputs are
Append/Gather paths with varno-0 output Vars and no statistics, group
counts come from the children, and MergeAppend is setop-specific. The path
is cost-based; enable_hashagg remains a soft penalty and
enable_parallel_append=off still works with a plain Append.
Group estimates. The partial hash path is sensitive to the child group
estimate. The patch adds SELFLAG_USED_MULTIVAR_CLAMP and
estimate_num_groups_unreliable(), and checks the worst-case worker hash
table against hash_mem when the estimate is weak. It deliberately uses
EstimateSetOpHashTableSpace() as a conservative allowance for HashAgg: a
generic estimate caused a near-unique 200k-row case to choose worker-side
deduplication and run about 28% slower than serial. A HashAgg-specific
estimator is a possible follow-up.
Implicit LIMIT 1. The constant check mirrors SELECT DISTINCT: it accepts
pseudo-constant expressions or expressions pinned to a constant in the top
join domain. It rejects broken ECs and outer-join-nullable Vars/PHVs, and
does not chase casts. Children without FROM are skipped. The limit is
applied to partial paths too, so a constant branch does not force a serial
Append.
One open question is early stopping: volatile quals/FROM/CTEs may be
evaluated fewer times, and a locking clause hidden in a non-pullable
subquery or CTE locks only the first row. This mirrors DISTINCT's LIMIT 1
behavior and is documented and tested, including FOR UPDATE/FOR SHARE, CTE,
zero-column, and infinite-recursive cases. Is this acceptable for set
operations given the DISTINCT precedent, or should setop children with
rowMarks be excluded entirely?
The UPPERREL_PARTIAL_SETOP stage is optional and may be invoked more than
once. Initially, output_rel contains partial paths; extensions may ignore
this stage. Currently, grouped, DISTINCT, and nested-setop children
generally do not expose partial paths, so the new UNION path is mainly
exercised by plain children; their estimates are still handled
conservatively.
Performance
-----------
1M rows per table, i5-12500H/WSL2, release build; median of 4 runs. Base -> patched:
2-way low-cardinality UNION: 158.7 -> 60.2 ms 2.64x
3-way low-cardinality UNION: 237.0 -> 86.0 ms 2.76x
4-way low-cardinality UNION: 317.4 -> 113.1 ms 2.81x
no statistics: 173.6 -> 76.3 ms 2.27x
extended statistics: 181.3 -> 69.8 ms 2.60x
near-unique key: 628.8 -> 633.3 ms 0.99x
constant + varying: 116.5 -> 28.8 ms 4.05x
constant + varying, serial: 118.5 -> 69.4 ms 1.71x
UNION ALL control: 20.8 -> 21.1 ms 0.99x
Near-unique stays serial; DISTINCT, INTERSECT, EXCEPT, and grouped-child
controls are unchanged within noise. The full SQL used for these
measurements is attached.
Testing
-------
The following all pass: make check (239/239), test_upper_paths_hook (1/1),
a Meson cassert build and regression tests, and make check-world.
Regression coverage includes plan shape, memory fallback, GUCs, ALL/non-ALL,
EC constants, outer-join nullability, zero-column/no-FROM, sublinks,
locking, and finite/infinite recursive CTEs.
Patches
-------
0001-Extract-shared-de-duplication-helpers-and-gate-DISTI.patch
0002-Add-parallel-partial-deduplication-for-UNION.patch
0003-Add-implicit-LIMIT-1-for-constant-set-operation-chil.patch
Regards,
Xiangxin
| Attachment | Content-Type | Size |
|---|---|---|
| 0001-Extract-shared-de-duplication-helpers-and-gate-DISTI.patch | application/octet-stream | 28.4 KB |
| 0002-Add-parallel-partial-deduplication-for-UNION.patch | application/octet-stream | 51.7 KB |
| 0003-Add-implicit-LIMIT-1-for-constant-set-operation-chil.patch | application/octet-stream | 60.0 KB |
| performance-test.sql | application/octet-stream | 8.7 KB |
| From | Date | Subject | |
|---|---|---|---|
| Next Message | Prashant Chinnam | 2026-10-22 03:59:20 | Re: [DEPARSE] Operators on ROW() constructors read back as row comparisons |
| Previous Message | shihao zhong | 2026-10-22 03:54:40 | Re: Trying out <stdatomic.h> |