Re: [PATCH] Rewrite undirected edge patterns in GRAPH_TABLE using UNION ALL - Mailing list pgsql-hackers
| From | ayoub.kazar@data-bene.io |
|---|---|
| Subject | Re: [PATCH] Rewrite undirected edge patterns in GRAPH_TABLE using UNION ALL |
| Date | |
| Msg-id | 85f9aba399cb95eedd23071870bb43a8@data-bene.io Whole thread |
| In response to | Re: [PATCH] Rewrite undirected edge patterns in GRAPH_TABLE using UNION ALL (ayoub.kazar@data-bene.io) |
| List | pgsql-hackers |
On 2026-07-16 15:46, ayoub.kazar@data-bene.io wrote: > 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 <ayoub.kazar@data-bene.io> 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 Oops, forgot to put ref to repo: [3] https://github.com/AyoubKaz07/lsqb-fork
pgsql-hackers by date: