On 2026-07-16 14:27, Ashutosh Bapat wrote:
Hi Ayoub,

Thanks for the proposal. I think it's useful.

On Thu, Jul 16, 2026 at 2:46 PM <[email protected]> wrote:

Hello everyone,

The attached patch changes how GRAPH_TABLE rewrites undirected edge
patterns
(-[e]-) to UNION ALL subquery instead of emitting an OR on both
directions quals.

Background
----------

When an undirected edge pattern matches an edge table whose source and
destination vertex table are the same, the current implementation
combines the equi-join
conditions for both traversal directions into a single OR qual:

     WHERE (e.src = v1.id AND e.dst = v2.id)
        OR (e.src = v2.id AND e.dst = v1.id)

This generally implies using BitmapOr or a full OR evaluation which is
very unefficient in queries with large intermediate results (see
benchmarks below).

What can we do
--------------

For the case where src and dest vertex patterns are different path
factors, but same path element,
the edge relation RTE is replaced with a UNION ALL subquery:

     (SELECT src, dst, ...other cols... FROM edge        -- forward
branch
      UNION ALL
      SELECT dst, src, ...other cols... FROM edge        -- backward
branch
WHERE NOT (src = dst)) -- this excludes
self-loops



Benchamrks
----------
Using LSQB from LDBC [1], [2] picking only Q6 and Q9 which are the
hardest queries (very large intermediate results) in the benchmark
(doing undirected edge patterns, which is our target for improvement).
Everything is ran on an Intel 1255U, 16GB RAM (everything warm), using
Docker, running vscode and firefox at the same time.

From [2] you can see query shapes, dataset sizes, degree distribution,
etc.

[2] points to a paper from where I couldn't find ready-to-run script
to verify your results. Will it be possible for you to create a
stand-alone benchmark or point to scripts which can be run against
PostgreSQL with less efforts?

Here's a github repo [3] containing a full benchmark fork from original implementation.
To run a specific system's benchs, cd into the system's dir and run:

To download data:
export MAX_SF=1
scripts/download-projected-fk-data-sets.sh (SQL/PGQ uses this)
scripts/download-merged-fk-data-sets.sh (SQL only systems should use this schema)

set MAX_SF to your max scale factor you want (everything below it will be downloaded), original readme talks about these anyway too.


export SF=1
./init-and-load.sh && ./run.sh && ./stop.sh

./init-and-load.sh only loads and starts the container so you can do whatever you want with psql later. ./run.sh runs the benchmark queries (for postgres SQL/PGQ i've only put queries that are supported, queries with multi path patterns are left out as SQL only (not relevant to our discussion))

Postgres systems also have config with them too that you can change (they are set to something that matches my machine) For postgres-patched you should compile our patch and build image for it (see readme.md in postgres-patched).

./stop.sh kills the container

You can also use benchmark.sh which runs different systems with different SFs (at choice) all together in same script.

You find the results in results dir.




Here are both queries so you don't have to...

Q6:
SELECT count(*)
FROM GRAPH_TABLE (lsqb
MATCH (person1 IS Person)-[k IS knows]-(person2 IS Person)-[k2 IS
knows]-(person3 IS Person)-[hi IS hasInterest]->(T IS Tag)
     WHERE person1.id <> person3.id
     COLUMNS (1 AS dummy)
);

Q9:
SELECT count(*)
FROM GRAPH_TABLE (lsqb
   MATCH (person1 IS Person)-[k IS knows]-(person2 IS Person)-[k2 IS
knows]-(person3 IS Person)-[hi IS hasInterest]->(T IS Tag)
   WHERE person1.id <> person3.id
   COLUMNS (person1.id as p1_id, person3.id as p3_id)
) g
LEFT JOIN (select person1id, person2id FROM Person_knows_Person UNION
ALL select person2id, person1id from Person_knows_person) pkp3
        ON pkp3.Person1Id = g.p1_id
       AND pkp3.Person2Id = g.p3_id
     WHERE pkp3.Person1Id IS NULL;


These queries have 7-way joins. But I think the join of interest here
is only (person1 IS Person)-[k IS knows]-(person2 IS Person). If you
could show results with just that path pattern, it will be easier to
understand how the plan changes.

Understandable, so using the LSQB dataset of SF 1, we craft simple query to see the optimization:

In PostgreSQL 19beta1

explain analyze SELECT count(*)
FROM GRAPH_TABLE (lsqb
    MATCH (person1 IS Person)-[k  IS knows]-(person2 IS Person)
    COLUMNS (1 AS dummy)
);
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=263539476.97..263539476.97 rows=1 width=8) (actual time=179822.587..179822.588 rows=1.00 loops=1)
   Buffers: shared hit=726452684
-> Nested Loop (cost=1.04..263538345.50 rows=452586 width=0) (actual time=6.756..179801.924 rows=452586.00 loops=1)
         Buffers: shared hit=726452684
-> Nested Loop (cost=0.00..1512845.50 rows=121000000 width=16) (actual time=0.036..5676.880 rows=121000000.00 loops=1)
               Buffers: shared hit=98
-> Seq Scan on person (cost=0.00..159.00 rows=11000 width=8) (actual time=0.017..2.172 rows=11000.00 loops=1)
                     Buffers: shared hit=49
-> Materialize (cost=0.00..214.00 rows=11000 width=8) (actual time=0.000..0.164 rows=11000.00 loops=11000)
                     Storage: Memory  Maximum Storage: 472kB
                     Buffers: shared hit=49
-> Seq Scan on person person_1 (cost=0.00..159.00 rows=11000 width=8) (actual time=0.006..0.390 rows=11000.00 loops=1)
                           Buffers: shared hit=49
-> Bitmap Heap Scan on person_knows_person (cost=1.04..2.16 rows=1 width=16) (actual time=0.001..0.001 rows=0.00 loops=121000000) Recheck Cond: (((person.id = person1id) AND (person_1.id = person2id)) OR ((person_1.id = person1id) AND (person.id = person2id)))
               Heap Blocks: exact=452586
               Buffers: shared hit=726452586
-> BitmapOr (cost=1.04..1.04 rows=1 width=0) (actual time=0.001..0.001 rows=0.00 loops=121000000)
                     Buffers: shared hit=726000000
-> Bitmap Index Scan on person_knows_person_pkey (cost=0.00..0.52 rows=1 width=0) (actual time=0.000..0.000 rows=0.00 loops=121000000) Index Cond: ((person1id = person.id) AND (person2id = person_1.id))
                           Index Searches: 121000000
                           Buffers: shared hit=363000000
-> Bitmap Index Scan on person_knows_person_pkey (cost=0.00..0.52 rows=1 width=0) (actual time=0.001..0.001 rows=0.00 loops=121000000) Index Cond: ((person1id = person_1.id) AND (person2id = person.id))
                           Index Searches: 121000000
                           Buffers: shared hit=363000000
 Planning Time: 0.358 ms
 Execution Time: 179822.739 ms
(29 rows)



In PostgreSQL patched:

QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=9483.00..9483.01 rows=1 width=8) (actual time=37.633..39.226 rows=1.00 loops=1)
   Buffers: shared hit=2790
-> Gather (cost=9482.78..9482.99 rows=2 width=8) (actual time=37.459..39.219 rows=3.00 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=2790
-> Partial Aggregate (cost=8482.78..8482.79 rows=1 width=8) (actual time=33.023..33.027 rows=1.00 loops=3)
               Buffers: shared hit=2790
-> Hash Join (cost=593.00..8012.52 rows=188106 width=0) (actual time=2.452..30.330 rows=150862.00 loops=3) Hash Cond: (person_knows_person_1.person1id = person_1.id)
                     Buffers: shared hit=2790
-> Hash Join (cost=296.50..7222.05 rows=188106 width=8) (actual time=1.241..18.724 rows=150862.00 loops=3) Hash Cond: (person_knows_person_1.person2id = person.id)
                           Buffers: shared hit=2643
-> Parallel Append (cost=0.00..6431.58 rows=188106 width=16) (actual time=0.014..7.103 rows=150862.00 loops=3)
                                 Buffers: shared hit=2496
-> Parallel Seq Scan on person_knows_person person_knows_person_1 (cost=0.00..2911.92 rows=132448 width=16) (actual time=0.009..2.916 rows=75431.00 loops=3)
                                       Filter: (person1id <> person2id)
                                       Buffers: shared hit=1248
-> Parallel Seq Scan on person_knows_person (cost=0.00..2579.14 rows=133114 width=16) (actual time=0.010..2.370 rows=113146.50 loops=2)
                                       Buffers: shared hit=1248
-> Hash (cost=159.00..159.00 rows=11000 width=8) (actual time=1.170..1.170 rows=11000.00 loops=3) Buckets: 16384 Batches: 1 Memory Usage: 558kB
                                 Buffers: shared hit=147
-> Seq Scan on person (cost=0.00..159.00 rows=11000 width=8) (actual time=0.012..0.338 rows=11000.00 loops=3)
                                       Buffers: shared hit=147
-> Hash (cost=159.00..159.00 rows=11000 width=8) (actual time=1.154..1.155 rows=11000.00 loops=3) Buckets: 16384 Batches: 1 Memory Usage: 558kB
                           Buffers: shared hit=147
-> Seq Scan on person person_1 (cost=0.00..159.00 rows=11000 width=8) (actual time=0.029..0.418 rows=11000.00 loops=3)
                                 Buffers: shared hit=147
 Planning Time: 0.454 ms
 Execution Time: 39.359 ms
(33 rows)

Time: 40.419 ms

Both have same setup, indexes, etc.


Your query has two such edges in any direction, which is an indicator
that you have thought about multiple any directional queries as well.
Adding a test like that in graph_table.sql would be helpful; we have
queries with one edge in any direction but not multiple.

Format:
Postgres-Version ScaleFactor Query-Number Latency(s)  query result
(count(*))

SF 0.1:
PostgreSQL-PGQ (v19beta1) 0.1 6 91.6983 55607896
PostgreSQL-PGQ (v19beta1) 0.1 9 99.2815 51009398
PostgreSQL-Patched 0.1 6 1.6103 55607896
PostgreSQL-Patched 0.1 9 2.1719 51009398

SF 0.3: (timeout set to 300 seconds)
PostgreSQL-PGQ (v19beta1) 0.3 6 300.0021 TIMEOUT
PostgreSQL-PGQ (v19beta1) 0.3 9 300.3658 TIMEOUT
PostgreSQL-Patched 0.3 6 9.1556 285509755
PostgreSQL-Patched 0.3 9 11.0674 268837983

SF 1: (timeout set to 40 minutes)
PostgreSQL-PGQ (v19beta1) 1 6 2400.1786 TIMEOUT
PostgreSQL-PGQ (v19beta1) 1 9 2400.1071 TIMEOUT
PostgreSQL-Patched 1 6 41.7566 1668134320
PostgreSQL-Patched 1 9 98.0370 1596153418


It's hard for me to understand what all those numbers are (some column
headers would help). I can guess, but it's better to be sure. And if
my guess is correct the results are impressive.
The format of header is same as mentioned:
Postgres-Version ScaleFactor Query-Number Latency(s) query result(number of rows: its a count(*))

Example:
PostgreSQL-Patched 0.3 6 9.1556 285509755

PostgreSQL-Patched: is the system being benchmarked.
0.3 is the scale factor.
6 is the query number in the LSQB benchmark.
9.1556 execution time in seconds.
285509755 row count (all queries are count(*) at the very end).


Does your optimization work only when the source and destination are
the same vertex tables OR it works for different source and
destination tables as well? Your example indicates the first, so
please confirm.

If i'm not mistaken, semantically we only return both directions results if source and destination are the same vertex tables, think of (person)-[works_at]-(company) ; this relation would've been defined where person is its source and company as it destination, so we would never return an edge row that is directed from company -> person, therefore we only care about a same vertex tables case (for the same reason it was for the original implementation with OR).


I am wondering whether we have an opportunity for some kind of planner
optimization here. What you are effectively claiming is query Q1 below
performs worse than query Q2 below.
Q1. SELECT * from t1 a, t2 b, t1 a where a.key = b.src_key AND
b.dst_key = c.key OR c.key = b.src_key AND b.dst_key = a.key
Q2. SELECT * FROM t1 a, t2 b, t1 a WHERE a.key = b.src_key AND
b.dst_key = c.key UNION ALL SELECT * FROM t1 a, t2 b, t1 a WHERE c.key
= b.src_key AND b.dst_key = a.key

Q2 is the equivalent of what Oracle does, its makes a UNION ALL between two queries that each work on a single direction.

What our patch does: It generates both edge directions (tuples) with UNION ALL, then continue its join back to the vertices, so we only replaced the edge path_element's RTE_RELATION by emitting basically the same edge table but its tuples are directed in both directions

(SELECT src, dst, ...other cols... FROM edge) -- this is forward branch
UNION ALL
(SELECT dst, src, ...other cols... FROM edge WHERE NOT (src = dst) ) -- this is backward branch
)
This would emit from the original edge table containing an edge (src=1, dest=2)
the result of:
(src=1, dest=2)
(src=2, dest=1)

at this point the only thing left is joining back.

WHERE NOT (src = dst) to exclude self loops (which would have already appeared in the forward branch)

DuckPGQ does same approach as this patch, but it has a bug where it duplicates the self loop edge rows, hence the usage of WHERE NOT (src = dst) in one of the branches.


I see that BitmapOr is performing some kind of UNION ALL within it.
Why isn't that speeding up the first query? Is it that the plan with
BitmapOr doesn't use parallel query? Is there some opportunity to
improve BitmapOr itself? I could not find the unpatched EXPLAIN
ANALYZE plans to figure that out myself.
I didn't understand this very well, but we'll comeback to it after this when the previous clarifications have made it clearer.

I think if you use a smaller dataset which is large enough to show the
performance difference and use queries with smaller path patterns, you
will be able to finish the queries within a reasonable time and get
full EXPLAIN ANALYZE plans even for unpatched SQL/PGQ.


Regards,
Ayoub


Reply via email to