Plan a filtering inner join as a semijoin

From: William Bernbaum <wbernbaum(at)dwdev(dot)com>
To: "pgsql-hackers(at)lists(dot)postgresql(dot)org" <pgsql-hackers(at)lists(dot)postgresql(dot)org>
Subject: Plan a filtering inner join as a semijoin
Date: 2026-08-26 03:02:18
Message-ID: PH0PR18MB444307FAC570FFD7F2457D4DA6AE2@PH0PR18MB4443.namprd18.prod.outlook.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Hey hackers,

Recently I improved a dynamic SQL compiler (an ORM-like layer) using a
pattern that seems to generalize to the Postgres planner. I taught the
layer to emit an EXISTS subquery when the target relation only served to
filter, rather than fanning out a join whose extra rows were discarded.

That led me into the join elimination machinery, and to the attached draft
patch. It adds convert_joins_to_semijoins(), which recognizes an inner
join that only restricts which rows survive and adds a JOIN_SEMI
SpecialJoinInfo for the filtering relation. Two conditions must hold: the
query must discard duplicates, and nothing outside the join clauses may
reference the relation.

The patch works, but only by being narrowed to the point where most of the
idea goes uncaptured.

Results: enable_semijoin_conversion off versus on, same data, serial
plans, work_mem 4MB, median of 5 runs. 100,000 posts and 10,000 authors;
fanout is the number of matching righthand rows per driver row, so it is
exactly the number of duplicate rows the join produces and the dedup step
removes. Fanout =1 is the control.

query fanout =1 =2 =8 =32
--------------------------------------------------------------
DISTINCT over a to-many join 0.98x 1.14x 4.64x 9.42x
count(DISTINCT) over a to-many 0.98x 0.93x 2.75x 11.06x
GROUP BY with max() 1.01x 1.17x 2.33x 6.99x
DISTINCT, driver also filtered 0.92x 1.19x 1.33x 6.87x

The patch lifts one relation at a time. The righthand side must be a single relation;
a chain is declined outright. (I discovered that LHS selectivity trumps row
multiplicativity when there is no index to probe). This forced me to bind the RHS
and forgo most of the upside.

From the same run, at fanout 32:

SELECT a.id, a.name, a.country FROM author a
WHERE EXISTS (SELECT 1
FROM post p JOIN comment c ON c.post_id = p.id
WHERE p.author_id = a.id AND c.spam
);
Master 4527ms
hand-written EXISTS 104ms 43.6x

Which brings me to the layering question. The neighboring machinery here increases
planner freedom: join elimination removes a relation the query can't observe, self-join
elimination removes a redundant one; etc. My patch (currently) decreases it.

My intent now is to follow the eager aggregation work and build at a lower layer.
Rather than replacing joins, offer the planner an alternative to choose on cost. Concretely,
a parallel "distinct relation", holding rows made distinct on the columns the query can
observe, carried through the join search, competing on cost, with no SpecialJoinInfo
and no order constraint.

One caveat: Reaching the ceiling will certainly require existence semantics and an
index-driven exit. So a distinct relation would have to be able to source a semijoin path
(not just a pre-deduplicated one). And I wonder whether that can be expressed without
reintroducing the join-order/selectivity constraint?

Patch attached; it applies to master today.

Thanks,
Will

Attachment Content-Type Size
0002-semijoin-v1-patch-b.patch application/octet-stream 38.2 KB
0003-semijoin-v1-patch-c.patch application/octet-stream 32.3 KB
0004-semijoin-v1-patch-d.patch application/octet-stream 10.0 KB
0001-semijoin-v1-patch-a.patch application/octet-stream 1.9 KB

Responses

Browse pgsql-hackers by date

  From Date Subject
Next Message Quan Zongliang 2026-08-26 03:10:02 Re: Walreceiver create temp slot more than once when timeline switch
Previous Message Kyotaro Horiguchi 2026-08-26 02:02:40 Re: Assertion failure in GetSubscriptionRelations() with concurrent DROP TABLE