[PATCH 0/3] Improve UNION de-duplication

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

Browse pgsql-hackers by date

  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>