Benchmark methodology
bench/corpus/suite.py head-to-head output (DESIGN.md §12 “Real graphs”,
§13 M11; bench/corpus/README.md describes the runner). There is one file per
dataset: every medium dataset, and four of the tiny ones (lesmis, which is
weighted; polblogs; facebook; wn18rr). The other tiny graphs are left to the
correctness suite. karate, celegans, london_transport and kg-codex-s are
small enough that their times would be round trips, and transport-airline
was left out by design. The large tier is deferred (DESIGN.md §13).
Each file holds the provenance, the machine, every engine’s versions and
settings (describe), its load phases, and for each workload the cold time,
the warm times and their median, the check against the reference and the
partition’s modularity. The files at the top level are the M11 run,
against pg_warren 0.7.0; pggraph-1.2.1/ holds a later run of pgGraph
1.2.1 beside pg_warren 0.8.0 and igraph, described in its own section
below.
Read the caveats before quoting a number. This is one run on one shared virtual machine. The engines keep their results differently, are cold in different ways, and do not use the same number of threads. One table cell is not a verdict on an engine.
Provenance
Section titled “Provenance”| pg_warren | 0.7.0, release build of ae51d77 (cargo pgrx package into a private tree), PostgreSQL 16.15, a scratch cluster with the soak’s settings (settings in each file: shared_buffers = 1GB, fsync = off, synchronous_commit = off), warren.threads = 4; pg_warren.so sha256 18124b35bc64aea8d1deba8e1040ac5cb2b9e733fcabd84ce4bf93f01874274a |
| igraph | python-igraph 1.0.0 (igraph’s C core), CPython 3.13.16, in the runner’s process |
| Neo4j | Community 2026.10.1 (sha256 afcf4986…3e78, Neo4j’s published checksum), OpenJDK 21.0.12, heap 4 GB (initial = max), page cache 2 GB, the shipped G1 flags, auth off, Bolt on loopback, Python driver 6.4.1 |
| GDS | Graph Data Science 2026.10.1 (sha256 1cdc0372…c133, pinned at the first fetch: GDS publishes no checksum), no licence key (Community: at most 4 threads) |
| Harness | 09c0a6e (bench/corpus/head_to_head/), suite.py head-to-head <the 12 datasets> --extension-revision ae51d77 --extension-profile release --timeout 300, with the defaults --repeat 5 --budget 120 --threads 4 --betweenness-work 5e8 |
| Machine | 4-vCPU Intel Xeon VM (no SMT), 15 GiB RAM, no swap, kernel 6.18.44, shared with other agents’ work (hardware.load_average in each file: 0.8–1.6 at the start of each dataset) |
Downloads, extracted installs and Neo4j’s databases live under
/var/tmp/warren-corpus/head-to-head, never in the repository; each
dataset’s Neo4j database was deleted once it had been measured.
Caveats
Section titled “Caveats”Read every number here with these in mind.
- One machine, shared, one run. A 4-vCPU Intel Xeon VM (no SMT), 15 GiB
RAM, no swap, kernel 6.18.44, with other agents’ work on it (load
averages are in each file,
hardware.load_average). The engines ran one at a time, each dataset in turn; nothing else of the head-to-head ran meanwhile, but the Postgres cluster stayed up (idle) while Neo4j ran, and the reverse. One run of the suite: no confidence intervals beyond the warm runs’ spread (warm_secondsin each file). - Threads are not matched one for one, and cannot be. pg_warren ran with
warren.threads = 4and GDS withconcurrency: 4(Community edition’s maximum); igraph’s algorithms are sequential and Neo4j Community runs a Cypher query on one thread. A row where igraph wins is a sequential C library beating 4 threads; a row where pg_warren wins over igraph may be its threads. - Times are wall clock at the client, around the call that computes the result into the engine’s own memory: an SQL statement over a local socket (pg_warren), a Python call (igraph), a Bolt query over loopback (Neo4j). Round trips (about 0.1–0.3 ms for pg_warren and 1–10 ms for Bolt here) are in the times and dominate the sub-millisecond rows. Reading the answer out is not timed.
- Each engine keeps its result differently. pg_warren’s library
functions (
pagerank,louvain,leiden,degree_vector) persist their output vector to the catalog, since they have nopersistargument;trianglesandbetweenness_centralityran withpersist := false. GDS ran inmutatemode (a node property in the in-memory projection). igraph returns a Python list. Cypher aggregates in the server. The Postgres cluster ran with the soak’s settings,fsync = offandsynchronous_commit = offamong them, so a persisted vector costs no flush; Neo4j’s defaults were kept (its transactions are durable). - Cold means something different in each engine. pg_warren: after
warren.evict, so the version is read back from its segments (the heap pages are in shared buffers or the OS cache; it is not a cold disk). Neo4j: after a server restart (empty page cache, plan cache and a cold JVM: JIT and class loading), and for GDS new projections too; the OS file cache stays warm. igraph keeps nothing between calls, so its cold run is just the first. Neo4j’s cold column is mostly the JVM. - Loading is not one thing. pg_warren’s
ingestisload.sql(CREATE TABLE,\copyfrom the CSVs, ANALYZE) and itsbuildonewarren.refresh(the median of three); igraph’singestreads the CSVs with Python’s csv module, itsbuildmakes the graph and folds parallel edges (and the undirected view); Neo4j’simportisneo4j-admin database import fulloffline (a JVM start included),startthe server start until Bolt answers andindexthe id index; GDS’sproject(andproject undirectedfor a directed graph) the in-memory projections from the store. pg_warren’s and igraph’s loads read the same CSVs; the sum of the phases is the time to first query. - What each engine computes.
- PageRank: 20 propagation steps from the start vector, damping 0.85,
in pg_warren (its library’s loop, NetworkX’s iteration: scores sum to
1, a dangling node’s rank spread evenly) and GDS (
maxIterations: 21, since GDS counts its initial vector as the first iteration; unnormalised: x₀ = 0.15, a dangling node’s rank lost). igraph’s PRPACK solves for the fixed point instead; it cannot stop at 20 steps. Each is checked against a reference of its own convention (within 10⁻⁹), so the three answers are each right and not equal to each other. - Communities: Louvain and Leiden are heuristics with their own
randomness, defaults and stopping rules (pg_warren: seed 0, tolerance
10⁻⁷; igraph: seeded Python RNG, Leiden 2 iterations; GDS: its
defaults, Leiden
randomSeed: 1). Each partition is scored by the same modularity (the reference’s, in the undirected view) and held to the correctness suite’s bound: at least igraph Louvain’s modularity less 0.02. The modularity each reached is in the files (check.modularity). - Projections: GDS reads a directed graph’s triangles, Louvain and
Leiden on an
UNDIRECTEDprojection with parallel relationships folded (SINGLE), so a reciprocal pair weighs 1 where pg_warren’s undirected view weighs it 2; pg_warren and igraph optimise the latter. - BFS depths: pg_warren
bfswith no depth bound; igraphdistances; GDSallShortestPaths.deltawith unit weights, mutated as relationships (itsstreammode holds every target’s path and ran out of a 4 GB heap on roadNet-TX); CyphershortestPathfrom the start to each other node (the newerANY SHORTESTpassed 600 s on facebook’s 4,039 nodes). - Degree: distinct out-neighbours; Cypher counts them per node, GDS’s
degree reads its
SINGLE-folded projection. - Shortest paths: 25 seeded pairs in one call (one SQL statement, one
Cypher query, a loop of igraph calls). igraph’s
distanceswith a target stops at the target but first builds the whole graph’s adjacency list, on every call; the pgGraph run’spaths/uses the fasterget_shortest_path(see that section). pg_warren’sshortest_pathand Cypher’sshortestPathsearch from both ends. - Triangles: per node in pg_warren, igraph (local transitivity times the pairs of neighbours, on the simple graph) and GDS; a total in Cypher.
- PageRank: 20 propagation steps from the start vector, damping 0.85,
in pg_warren (its library’s loop, NetworkX’s iteration: scores sum to
1, a dangling node’s rank spread evenly) and GDS (
- Not comparable, or not run. Cypher has no PageRank, components,
Louvain, Leiden or betweenness (listed below, with why) and no weighted
shortest path; APOC was not used. pg_warren has no weighted
single-source shortest path (only
dijkstrabetween two nodes), so the weighted dataset (lesmis) is compared on pairs only. Betweenness runs only where N·M ≤ 5·10⁸ (lesmis, polblogs, facebook). The large tier (LiveJournal, ogbn-mag) is deferred (DESIGN.md §13). Neo4j Enterprise and GDS Enterprise (more threads, the parallel Cypher runtime) were not tried. - Neo4j’s configuration: Community 2026.10.1 and GDS 2026.10.1 on OpenJDK 21.0.12, heap 4 GB (initial = max), page cache 2 GB, the shipped JVM flags (G1), auth off, Bolt on loopback, Python driver 6.4.1. GDS without a licence key is Community: 4 threads at most.
Findings
Section titled “Findings”The comparisons below use warm medians unless they say otherwise. “N× faster” compares like with like under the caveats above.
Correctness. Every counted time comes from an answer that matched the reference. That means exactly for degrees, reach, depths, path lengths, components and triangles; within 10⁻⁹ for PageRank (under each engine’s own convention) and betweenness; and at or above the quality bound for communities. Three things kept a time out of the tables:
- GDS Leiden on email-enron fell below the bound. It reached Q = 0.590, against a bound of 0.595 (igraph Louvain’s 0.615 less 0.02). pg_warren reached 0.626 and igraph’s Leiden 0.624. An earlier run of the same dataset gave GDS 0.594, also below, so this is GDS’s Leiden on its folded projection, not one unlucky seed.
- GDS Dijkstra on roadnet-tx stopped at its own memory guard. The error was “Memory required to run Dijkstra (66409280b) exceeds current available memory (19565798b)”, from a 4 GB heap after the cold restart and projection. An earlier GDS-only run of roadnet-tx, made while the harness was being fixed, finished it in 7.4 s cold and 6.6 s warm. That run is not in the table.
- Cypher passed the 300 s timeout on BFS depths for the two road networks, and on triangles for yago3-10 and hetionet.
pg_warren had no wrong answer. Its partitions reached the highest
modularity of the three engines on 7 of 12 datasets for Louvain and 6 of 12
for Leiden, and within 0.0015 of the best on the rest. The modularity each
engine reached is check.modularity in each file.
Where pg_warren wins.
- Shortest paths between pairs. pg_warren’s bidirectional BFS
(
shortest_path) beat igraph on 11 of 12 datasets. The margin was 2–220×: dblp took 6 ms against 1.04 s, and roadnet-tx 1.46 s against 2.98 s. igraph’sdistanceswith a target builds the whole graph’s adjacency list for each pair before it searches; against igraph’s fasterget_shortest_path, pg_warren 0.8.0 won on 7 of 12 (the pgGraph section below). pg_warren also beat GDS Dijkstra by 5–370×, and Cypher’sshortestPathwarm on 11 of 11. Cypher’s warm times here are 18–330 ms; roadnet-tx took 5.9 s. - Louvain. pg_warren beat GDS everywhere, by 1.7–280×: GDS took 3–16 s warm on the medium tier. It beat igraph on 7 of 12 datasets, the large sparse ones in particular: dblp 1.16 s against 5.49 s, roadnet-tx 3.59 s against 15.8 s, and usroads-48 0.28 s against 0.76 s. These wins may come from its 4 threads against igraph’s one.
- Time to first query. pg_warren’s ingest and build (0.01–3.5 s) are on a par with igraph reading the same CSVs into Python. Neo4j needs 13–18 s before the first query: 3.7–8.5 s to import, 7.2–8.3 s to start the server and 1.3–2.6 s to build the id index, plus 1.0–1.8 s for the GDS projection. The first Cypher query after a start then takes about 2 s (a cold JVM).
- Traversals against Neo4j. BFS depths and BFS to depth 6 beat GDS warm on every dataset except dblp, where the two tie within 5%. BFS depths was 3–9× faster on the medium tier, for example roadnet-tx at 0.38 s against 2.0 s. Cypher took seconds for the same answer, or passed 300 s. BFS to depth 6 also beat igraph on roadnet-tx (0.38 ms against 0.60 ms), and BFS depths beat it on yago3-10 (4.9 ms against 13 ms).
Where pg_warren loses.
- Fixed cost per call. The cheapest calls take 0.2–2 ms on the tiny
graphs, where igraph answers in microseconds.
degree_vectorcosts 0.5 ms on lesmis against igraph’s 2 µs, and 3–90 ms on the medium tier, for example 32 ms on dblp against 1.7 ms. pg_warren pays for an SQL statement, the cache lookup and, for the library functions, persisting the output vector to the catalog. GDS’smutatecalls cost a flat 7–11 ms for the same reason (Bolt and the procedure framework). - PageRank. pg_warren was slower than igraph everywhere: 1.02× on dblp,
1000× on lesmis. Two things contribute. The library’s loop is PL/pgSQL, with about
1.5 ms of fixed overhead per iteration: lesmis’s 77 nodes take 29 ms,
against 28 µs. igraph’s PRPACK also solves a linear system instead of
iterating. GDS was faster than pg_warren on yago3-10 (0.15 s against
0.17 s) and on roadnet-tx (0.62 s against 1.0 s).
Since addressed (0.8.0):
pagerankis a native kernel, to the bit of the loop (DESIGN.md §6.7). Re-timed against igraph alone (“PageRank in 0.8.0”, below): 2.9–9.3× faster than this run, faster than igraph on 7 of 12 datasets (roadnet-tx 0.22 s against 0.81 s, dblp 0.11 s against 0.25 s) and slower on 5 (lesmis 3.1 ms against 29 µs, polblogs, wn18rr, cit-hepth and yago3-10 by 1.2–3.1×). - Triangles. igraph, on one thread, was 1.4–13× faster on every graph
except the road networks and wn18rr: facebook 12 ms against 154 ms, email-enron 23 ms against
185 ms, and hetionet 0.49 s against 4.17 s. GDS was also faster on five
of them.
warren-core/src/compute/triangles.rsintersects every neighbour’s row, which is O(Σ d²) on skewed degrees, where igraph and GDS orient edges by degree first. Since fixed (M11):trianglesnow orients edges by degree and finds each triangle once (DESIGN.md §6.7, §12). Re-timed against igraph alone, not in the tables here: facebook 7.0 ms and hetionet 0.116 s, faster than igraph on every graph but lesmis and polblogs. - Leiden. igraph was 1.9–180× faster everywhere (fb15k-237 0.14 s
against 4.42 s, yago3-10 0.79 s against 4.19 s), and GDS was faster on 7 of 11
datasets (roadnet-tx 2.5 s against 8.1 s). pg_warren’s Leiden runs a
level at a time through
warren.contract, persisting each quotient graph as a version in the catalog. igraph and GDS keep every level in memory. Since fixed (0.8.0):leidenis native, contracting each level from the one below and keeping the levels in memory, for the same partitions (DESIGN.md §6.7, Leiden’s levels). Re-timed against igraph alone (“Leiden in 0.8.0”, below): 1.3–21× faster than main before the change, and within 0.76–1.63× of igraph on the medium tier (faster on ss-gleich-usroads-48, dblp, yago3-10 and roadnet-tx). - Louvain and Leiden on graphs with relations have slowed down since the
timed run.
suite.py timeat 1ffdcd9 took 0.78 s and 1.94 s on fb15k-237; this run at ae51d77 took 1.32 s and 4.42 s. yago3-10 went from 1.29 s and 3.29 s to 1.39 s and 4.19 s, and hetionet from 1.86 s and 3.32 s to 2.25 s and 5.49 s. The graphs without relations are unchanged or faster (dblp, email-enron, roadnet-tx). Two changes landed between those commits: sparse per-relation offsets, and a modularity that weighs a pair 1 across relations (0.7.0). The quotients keep per-relation CSRs. Re-timed with one harness, the slowdown was real: 0.7.0’s counted-oncecontractlooked each stored edge up in every earlier relation’s row. It is fixed after this run (DESIGN.md §12, “A community regression in 0.7.0”); these tables are ae51d77’s. - Traversal and components against igraph. igraph was 1.2–60× faster for BFS reach, depths and components on most datasets. The exceptions are the two BFS wins above, and components on cit-hepth and fb15k-237. pg_warren’s traversals also return their rows to SQL; the timed query counts them.
- Not timed: weighted single-source paths. pg_warren’s weighted
single-source shortest path is
sssp_vector(Bellman-Ford withmin_plusproducts, persisting a vector), which this run does not time, so weighted lesmis is compared on pairs (dijkstra) only.
Neo4j. Plain Cypher on Community runs each query on one thread. Once
warm, it answers reach and pair queries in milliseconds to a few hundred.
It cannot express PageRank, components, Louvain, Leiden or betweenness. Its
shortestPath to every node and its triangle pattern take tens of seconds
to minutes on the medium tier. GDS answers everything, but every call costs
at least about 8 ms. Its Louvain was the slowest of the three engines on
every dataset but roadnet-tx (3–16 s on the medium tier). Its Leiden was
the fastest of the three on dblp, yago3-10, hetionet and roadnet-tx.
Loading (median seconds of each phase; ms and µs where marked)
Section titled “Loading (median seconds of each phase; ms and µs where marked)”pg_warren’s build is the median of three refresh runs. Neo4j’s phases are shared by Cypher and GDS: both read the same store.
| Dataset | Engine | Phases (median seconds) |
|---|---|---|
| lesmis | pg_warren | ingest 9.37 ms, build 2.29 ms |
| lesmis | igraph | ingest 253 µs, build 183 µs |
| lesmis | neo4j-cypher | import 3.65, start 7.90, index 1.52 |
| lesmis | neo4j-gds | import 3.65, start 7.90, index 1.52, project 1.04 |
| polblogs | pg_warren | ingest 0.024, build 9.13 ms |
| polblogs | igraph | ingest 5.82 ms, build 8.34 ms |
| polblogs | neo4j-cypher | import 4.18, start 8.30, index 1.56 |
| polblogs | neo4j-gds | import 4.18, start 8.30, index 1.56, project 1.02, project undirected 0.062 |
| pg_warren | ingest 0.045, build 0.026 | |
| igraph | ingest 0.026, build 0.015 | |
| neo4j-cypher | import 4.00, start 7.26, index 1.43 | |
| neo4j-gds | import 4.00, start 7.26, index 1.43, project 1.10 | |
| wn18rr | pg_warren | ingest 0.142, build 0.064 |
| wn18rr | igraph | ingest 0.084, build 0.051 |
| wn18rr | neo4j-cypher | import 4.48, start 7.27, index 1.39 |
| wn18rr | neo4j-gds | import 4.48, start 7.27, index 1.39, project 1.09, project undirected 0.155 |
| cit-hepth | pg_warren | ingest 0.145, build 0.086 |
| cit-hepth | igraph | ingest 0.140, build 0.174 |
| cit-hepth | neo4j-cypher | import 4.65, start 7.16, index 1.31 |
| cit-hepth | neo4j-gds | import 4.65, start 7.16, index 1.31, project 1.05, project undirected 0.114 |
| email-enron | pg_warren | ingest 0.149, build 0.099 |
| email-enron | igraph | ingest 0.145, build 0.075 |
| email-enron | neo4j-cypher | import 5.06, start 8.00, index 1.46 |
| email-enron | neo4j-gds | import 5.06, start 8.00, index 1.46, project 1.26 |
| fb15k-237 | pg_warren | ingest 0.288, build 0.183 |
| fb15k-237 | igraph | ingest 0.293, build 0.177 |
| fb15k-237 | neo4j-cypher | import 5.54, start 7.49, index 1.43 |
| fb15k-237 | neo4j-gds | import 5.54, start 7.49, index 1.43, project 1.12, project undirected 0.164 |
| ss-gleich-usroads-48 | pg_warren | ingest 0.159, build 0.078 |
| ss-gleich-usroads-48 | igraph | ingest 0.109, build 0.038 |
| ss-gleich-usroads-48 | neo4j-cypher | import 5.08, start 8.00, index 1.64 |
| ss-gleich-usroads-48 | neo4j-gds | import 5.08, start 8.00, index 1.64, project 1.20 |
| dblp | pg_warren | ingest 0.658, build 0.415 |
| dblp | igraph | ingest 0.751, build 0.398 |
| dblp | neo4j-cypher | import 6.64, start 7.89, index 1.64 |
| dblp | neo4j-gds | import 6.64, start 7.89, index 1.64, project 1.41 |
| yago3-10 | pg_warren | ingest 0.800, build 0.581 |
| yago3-10 | igraph | ingest 1.35, build 0.723 |
| yago3-10 | neo4j-cypher | import 6.68, start 7.16, index 1.43 |
| yago3-10 | neo4j-gds | import 6.68, start 7.16, index 1.43, project 1.33, project undirected 0.212 |
| hetionet | pg_warren | ingest 1.06, build 0.977 |
| hetionet | igraph | ingest 1.58, build 1.78 |
| hetionet | neo4j-cypher | import 8.46, start 7.26, index 1.42 |
| hetionet | neo4j-gds | import 8.46, start 7.26, index 1.42, project 1.35, project undirected 0.299 |
| roadnet-tx | pg_warren | ingest 2.23, build 1.24 |
| roadnet-tx | igraph | ingest 2.56, build 0.885 |
| roadnet-tx | neo4j-cypher | import 8.04, start 7.59, index 2.56 |
| roadnet-tx | neo4j-gds | import 8.04, start 7.59, index 2.56, project 1.79 |
Workloads (cold / warm median, seconds; ms and µs where marked)
Section titled “Workloads (cold / warm median, seconds; ms and µs where marked)”“—” means the engine cannot run the workload (reasons below). “timeout” means the run passed 300 s. “error” and “below bound” are explained under Findings. Betweenness ran only where N·M ≤ 5·10⁸.
| Dataset | Workload | pg_warren | igraph | neo4j-cypher | neo4j-gds |
|---|---|---|---|---|---|
| lesmis | degree | 2.23 ms / 501 µs | 9 µs / 2 µs | 1.90 / 0.013 | 0.095 / 0.010 |
| lesmis | bfs depth 6 | 892 µs / 217 µs | 11 µs / 4 µs | 1.82 / 7.89 ms | 0.440 / 0.016 |
| lesmis | bfs depths | 668 µs / 187 µs | 16 µs / 11 µs | 1.98 / 0.013 | 0.374 / 0.018 |
| lesmis | shortest paths | 1.66 ms / 855 µs | 465 µs / 330 µs | — | 1.05 / 0.211 |
| lesmis | pagerank 20 iterations | 0.035 / 0.029 | 70 µs / 28 µs | — | 0.222 / 0.063 |
| lesmis | components | 810 µs / 162 µs | 33 µs / 3 µs | — | 0.138 / 0.012 |
| lesmis | triangles | 871 µs / 251 µs | 55 µs / 23 µs | 2.16 / 0.013 | 0.131 / 0.017 |
| lesmis | louvain | 0.012 / 8.83 ms | 185 µs / 97 µs | — | 0.847 / 0.957 |
| lesmis | leiden | 0.032 / 0.029 | 198 µs / 161 µs | — | 0.353 / 0.153 |
| lesmis | betweenness | 1.19 ms / 578 µs | 587 µs / 538 µs | — | 0.136 / 0.015 |
| polblogs | degree | 2.30 ms / 935 µs | 218 µs / 6 µs | 1.94 / 0.015 | 0.099 / 9.67 ms |
| polblogs | bfs depth 6 | 1.38 ms / 538 µs | 108 µs / 45 µs | 1.98 / 0.011 | 0.487 / 0.014 |
| polblogs | bfs depths | 1.20 ms / 513 µs | 241 µs / 166 µs | 2.09 / 0.032 | 0.392 / 0.016 |
| polblogs | shortest paths | 1.63 ms / 802 µs | 3.41 ms / 3.10 ms | 2.10 / 0.018 | 0.703 / 0.075 |
| polblogs | pagerank 20 iterations | 0.041 / 0.035 | 2.04 ms / 1.63 ms | — | 0.244 / 0.059 |
| polblogs | components | 1.51 ms / 572 µs | 364 µs / 282 µs | — | 0.119 / 0.015 |
| polblogs | triangles | 0.021 / 0.021 | 1.89 ms / 1.71 ms | 3.67 / 1.24 | 0.148 / 0.028 |
| polblogs | louvain | 0.026 / 0.021 | 7.38 ms / 6.47 ms | — | 5.42 / 3.60 |
| polblogs | leiden | 0.064 / 0.059 | 5.85 ms / 5.90 ms | — | 0.480 / 0.194 |
| polblogs | betweenness | 0.101 / 0.093 | 0.082 / 0.080 | — | 0.353 / 0.071 |
| degree | 4.40 ms / 1.80 ms | 38 µs / 17 µs | 1.83 / 0.037 | 0.083 / 7.96 ms | |
| bfs depth 6 | 3.07 ms / 1.74 ms | 372 µs / 328 µs | 1.85 / 0.019 | 0.427 / 0.016 | |
| bfs depths | 3.13 ms / 1.70 ms | 930 µs / 867 µs | 2.17 / 0.224 | 0.374 / 0.027 | |
| shortest paths | 3.96 ms / 2.37 ms | 0.019 / 0.019 | 1.95 / 0.025 | 0.682 / 0.094 | |
| pagerank 20 iterations | 0.049 / 0.040 | 0.010 / 9.86 ms | — | 0.390 / 0.075 | |
| components | 3.40 ms / 1.59 ms | 677 µs / 93 µs | — | 0.126 / 0.011 | |
| triangles | 0.156 / 0.154 | 0.012 / 0.012 | 11.90 / 9.72 | 0.284 / 0.110 | |
| louvain | 0.040 / 0.036 | 0.027 / 0.025 | — | 12.03 / 10.15 | |
| leiden | 0.085 / 0.080 | 0.018 / 0.018 | — | 0.502 / 0.207 | |
| betweenness | 1.13 / 1.15 | 1.67 / 1.67 | — | 1.34 / 1.07 | |
| wn18rr | degree | 0.014 / 3.48 ms | 353 µs / 141 µs | 1.90 / 0.061 | 0.105 / 8.15 ms |
| wn18rr | bfs depth 6 | 0.011 / 2.56 ms | 693 µs / 248 µs | 1.75 / 0.016 | 0.460 / 0.015 |
| wn18rr | bfs depths | 0.013 / 5.05 ms | 3.92 ms / 3.82 ms | 5.01 / 3.05 | 0.417 / 0.034 |
| wn18rr | shortest paths | 8.93 ms / 1.32 ms | 0.080 / 0.079 | 2.16 / 0.023 | 0.806 / 0.171 |
| wn18rr | pagerank 20 iterations | 0.101 / 0.089 | 0.012 / 0.012 | — | 0.451 / 0.122 |
| wn18rr | components | 0.020 / 8.88 ms | 5.56 ms / 4.83 ms | — | 0.137 / 0.014 |
| wn18rr | triangles | 0.015 / 6.13 ms | 0.012 / 0.011 | 3.21 / 0.846 | 0.188 / 0.024 |
| wn18rr | louvain | 0.230 / 0.216 | 0.160 / 0.159 | — | 8.89 / 4.53 |
| wn18rr | leiden | 0.523 / 0.513 | 0.093 / 0.092 | — | 1.48 / 0.272 |
| cit-hepth | degree | 9.30 ms / 4.36 ms | 351 µs / 100 µs | 1.96 / 0.071 | 0.097 / 7.51 ms |
| cit-hepth | bfs depth 6 | 7.60 ms / 2.74 ms | 1.38 ms / 612 µs | 1.70 / 0.037 | 0.495 / 0.022 |
| cit-hepth | bfs depths | 8.37 ms / 4.75 ms | 4.00 ms / 3.93 ms | 16.83 / 15.14 | 0.460 / 0.042 |
| cit-hepth | shortest paths | 8.55 ms / 5.22 ms | 0.076 / 0.074 | 1.87 / 0.052 | 0.854 / 0.157 |
| cit-hepth | pagerank 20 iterations | 0.083 / 0.077 | 0.013 / 0.011 | — | 0.426 / 0.156 |
| cit-hepth | components | 0.012 / 6.47 ms | 9.92 ms / 6.91 ms | — | 0.158 / 0.021 |
| cit-hepth | triangles | 0.145 / 0.122 | 0.042 / 0.040 | 38.77 / 37.86 | 0.352 / 0.136 |
| cit-hepth | louvain | 0.237 / 0.202 | 0.454 / 0.453 | — | 8.00 / 7.97 |
| cit-hepth | leiden | 0.445 / 0.441 | 0.203 / 0.198 | — | 1.29 / 0.350 |
| email-enron | degree | 0.011 / 5.06 ms | 233 µs / 153 µs | 1.97 / 0.106 | 0.109 / 0.011 |
| email-enron | bfs depth 6 | 0.015 / 7.92 ms | 2.97 ms / 2.21 ms | 1.99 / 0.076 | 0.572 / 0.040 |
| email-enron | bfs depths | 0.012 / 8.22 ms | 5.24 ms / 5.08 ms | 6.72 / 4.53 | 0.549 / 0.057 |
| email-enron | shortest paths | 7.18 ms / 2.59 ms | 0.115 / 0.113 | 1.98 / 0.024 | 0.962 / 0.260 |
| email-enron | pagerank 20 iterations | 0.100 / 0.096 | 0.037 / 0.036 | — | 0.449 / 0.159 |
| email-enron | components | 0.013 / 8.02 ms | 4.17 ms / 3.21 ms | — | 0.176 / 0.020 |
| email-enron | triangles | 0.205 / 0.185 | 0.024 / 0.023 | 39.80 / 37.53 | 0.298 / 0.080 |
| email-enron | louvain | 0.154 / 0.146 | 0.207 / 0.205 | — | 6.83 / 7.07 |
| email-enron | leiden | 0.378 / 0.357 | 0.125 / 0.122 | — | below bound |
| fb15k-237 | degree | 0.066 / 3.34 ms | 286 µs / 52 µs | 1.88 / 0.079 | 0.084 / 7.58 ms |
| fb15k-237 | bfs depth 6 | 0.068 / 4.83 ms | 2.33 ms / 1.45 ms | 1.82 / 0.048 | 0.451 / 0.025 |
| fb15k-237 | bfs depths | 0.067 / 5.23 ms | 3.05 ms / 2.80 ms | 3.56 / 1.68 | 0.413 / 0.023 |
| fb15k-237 | shortest paths | 0.066 / 3.58 ms | 0.054 / 0.054 | 1.89 / 0.027 | 0.740 / 0.141 |
| fb15k-237 | pagerank 20 iterations | 0.135 / 0.069 | 0.015 / 0.014 | — | 0.405 / 0.129 |
| fb15k-237 | components | 0.066 / 4.67 ms | 7.38 ms / 6.18 ms | — | 0.117 / 0.017 |
| fb15k-237 | triangles | 0.196 / 0.136 | 0.020 / 0.018 | 159 / 166 | 0.276 / 0.075 |
| fb15k-237 | louvain | 1.39 / 1.32 | 0.257 / 0.257 | — | 8.42 / 5.74 |
| fb15k-237 | leiden | 4.51 / 4.42 | 0.150 / 0.144 | — | 0.968 / 0.459 |
| ss-gleich-usroads-48 | degree | 0.021 / 9.75 ms | 747 µs / 628 µs | 2.25 / 0.101 | 0.092 / 8.43 ms |
| ss-gleich-usroads-48 | bfs depth 6 | 8.70 ms / 323 µs | 202 µs / 21 µs | 1.87 / 7.78 ms | 0.445 / 0.015 |
| ss-gleich-usroads-48 | bfs depths | 0.038 / 0.033 | 0.017 / 0.016 | timeout | 0.859 / 0.236 |
| ss-gleich-usroads-48 | shortest paths | 0.139 / 0.128 | 0.278 / 0.275 | 2.26 / 0.329 | 1.30 / 0.615 |
| ss-gleich-usroads-48 | pagerank 20 iterations | 0.166 / 0.161 | 0.087 / 0.077 | — | 0.528 / 0.193 |
| ss-gleich-usroads-48 | components | 0.036 / 0.023 | 0.015 / 2.90 ms | — | 0.214 / 0.023 |
| ss-gleich-usroads-48 | triangles | 0.022 / 0.011 | 0.031 / 0.030 | 2.66 / 0.377 | 0.191 / 0.036 |
| ss-gleich-usroads-48 | louvain | 0.303 / 0.281 | 0.745 / 0.758 | — | 4.26 / 3.15 |
| ss-gleich-usroads-48 | leiden | 0.706 / 0.661 | 0.292 / 0.289 | — | 1.73 / 0.741 |
| dblp | degree | 0.065 / 0.032 | 2.40 ms / 1.72 ms | 2.38 / 0.436 | 0.096 / 8.55 ms |
| dblp | bfs depth 6 | 0.118 / 0.071 | 0.043 / 0.033 | 2.21 / 0.331 | 0.569 / 0.069 |
| dblp | bfs depths | 0.127 / 0.078 | 0.065 / 0.058 | 37.18 / 34.70 | 0.647 / 0.075 |
| dblp | shortest paths | 0.035 / 6.03 ms | 1.24 / 1.04 | 1.86 / 0.026 | 2.88 / 2.22 |
| dblp | pagerank 20 iterations | 0.386 / 0.306 | 0.284 / 0.299 | — | 0.996 / 0.443 |
| dblp | components | 0.118 / 0.071 | 0.049 / 7.48 ms | — | 0.290 / 0.034 |
| dblp | triangles | 0.276 / 0.220 | 0.165 / 0.159 | 17.52 / 14.93 | 0.565 / 0.272 |
| dblp | louvain | 1.18 / 1.16 | 5.19 / 5.49 | — | 18.78 / 12.73 |
| dblp | leiden | 2.79 / 2.78 | 1.34 / 1.33 | — | 2.35 / 0.932 |
| yago3-10 | degree | 0.052 / 0.014 | 912 µs / 485 µs | 2.29 / 0.308 | 0.081 / 7.04 ms |
| yago3-10 | bfs depth 6 | 0.054 / 5.20 ms | 1.91 ms / 801 µs | 1.94 / 0.030 | 0.424 / 0.017 |
| yago3-10 | bfs depths | 0.040 / 4.92 ms | 0.013 / 0.013 | 70.07 / 66.77 | 0.367 / 0.027 |
| yago3-10 | shortest paths | 0.033 / 1.19 ms | 0.260 / 0.260 | 1.87 / 0.019 | 0.741 / 0.135 |
| yago3-10 | pagerank 20 iterations | 0.200 / 0.169 | 0.040 / 0.035 | — | 0.485 / 0.146 |
| yago3-10 | components | 0.067 / 0.030 | 0.026 / 0.020 | — | 0.141 / 0.030 |
| yago3-10 | triangles | 0.228 / 0.172 | 0.069 / 0.059 | timeout | 0.358 / 0.133 |
| yago3-10 | louvain | 1.43 / 1.39 | 1.68 / 1.71 | — | 7.30 / 6.97 |
| yago3-10 | leiden | 4.19 / 4.19 | 0.781 / 0.787 | — | 1.93 / 0.712 |
| hetionet | degree | 0.061 / 0.013 | 629 µs / 180 µs | 2.60 / 0.592 | 0.079 / 7.37 ms |
| hetionet | bfs depth 6 | 0.064 / 0.014 | 4.26 ms / 2.89 ms | 2.10 / 0.241 | 0.644 / 0.061 |
| hetionet | bfs depths | 0.063 / 0.014 | 8.96 ms / 7.75 ms | 91.65 / 87.30 | 0.445 / 0.042 |
| hetionet | shortest paths | 0.052 / 2.71 ms | 0.165 / 0.162 | 1.85 / 0.023 | 0.866 / 0.241 |
| hetionet | pagerank 20 iterations | 0.167 / 0.103 | 0.060 / 0.053 | — | 0.589 / 0.375 |
| hetionet | components | 0.066 / 0.017 | 0.043 / 1.01 ms | — | 0.184 / 0.027 |
| hetionet | triangles | 4.25 / 4.17 | 0.487 / 0.494 | timeout | 3.10 / 2.88 |
| hetionet | louvain | 2.34 / 2.25 | 2.36 / 2.39 | — | 14.46 / 15.62 |
| hetionet | leiden | 5.66 / 5.49 | 1.40 / 1.43 | — | 1.41 / 0.761 |
| roadnet-tx | degree | 0.187 / 0.090 | 0.014 / 7.40 ms | 3.47 / 1.25 | 0.086 / 8.10 ms |
| roadnet-tx | bfs depth 6 | 0.096 / 380 µs | 1.14 ms / 596 µs | 1.82 / 9.56 ms | 0.433 / 0.016 |
| roadnet-tx | bfs depths | 0.476 / 0.382 | 0.179 / 0.169 | timeout | 2.86 / 2.00 |
| roadnet-tx | shortest paths | 1.59 / 1.46 | 3.06 / 2.98 | 7.94 / 5.87 | error |
| roadnet-tx | pagerank 20 iterations | 1.09 / 0.998 | 0.868 / 0.876 | — | 1.23 / 0.617 |
| roadnet-tx | components | 0.400 / 0.295 | 0.157 / 0.131 | — | 0.325 / 0.056 |
| roadnet-tx | triangles | 0.255 / 0.145 | 0.350 / 0.334 | 8.55 / 6.26 | 0.396 / 0.304 |
| roadnet-tx | louvain | 3.94 / 3.59 | 16.72 / 15.80 | — | 6.86 / 6.07 |
| roadnet-tx | leiden | 8.13 / 8.10 | 4.34 / 4.29 | — | 4.22 / 2.50 |
Not run by Cypher
Section titled “Not run by Cypher”- shortest paths (weighted lesmis only): Cypher’s shortest paths count hops (shortestPath, SHORTEST); a weighted one needs GDS or APOC
- pagerank 20 iterations: iterative over the whole graph’s state: no Cypher query expresses it (GDS or APOC do)
- components: a fixed point of label propagation, or a traversal per component: not one Cypher query
- louvain: a heuristic optimisation over the whole graph: not expressible in Cypher
- leiden: a heuristic optimisation over the whole graph: not expressible in Cypher
- betweenness: needs every pair’s shortest paths counted (Brandes): not a practical Cypher query
Leiden in 0.8.0 (re-timed against igraph alone)
Section titled “Leiden in 0.8.0 (re-timed against igraph alone)”leiden-0.8.0/ holds one file per dataset for each side. Both sides
are release builds (cargo pgrx package, PostgreSQL 16.15, the settings
above, warren.threads = 4). 4592b0e is main before the change: 0.7.0
with the community regression fixed, so faster than the ae51d77 column
above. 99aeb74 is native leiden with its levels in memory; rebased onto main’s later commits (examples and the website, none to the extension) it is f618847.
The command line, per dataset, alternated the two builds so they shared
the machine’s load:
suite.py head-to-head <dataset> --engine pg_warren [--engine igraph] --workload leiden --threads 4 --extension-profile release.
The machine was busier than in the M11 run, with a load average of 2.2–7.0
at the start of each dataset from other agents’ builds and tests. Read
the ratios rather than the absolute times. Both builds found the same
partition on every dataset: the same number of communities, and the same
modularity to the last bit.
| Dataset | before (4592b0e) | after (99aeb74) | speed-up | igraph | after / igraph |
|---|---|---|---|---|---|
| lesmis | 0.032 / 0.025 | 2.28 ms / 1.17 ms | 21× | 282 µs / 177 µs | 6.6 |
| polblogs | 0.080 / 0.081 | 0.014 / 0.012 | 6.7× | 5.84 ms / 5.74 ms | 2.1 |
| 0.096 / 0.082 | 0.037 / 0.030 | 2.7× | 0.019 / 0.020 | 1.5 | |
| wn18rr | 0.421 / 0.401 | 0.111 / 0.096 | 4.2× | 0.096 / 0.095 | 1.0 |
| cit-hepth | 0.483 / 0.451 | 0.315 / 0.299 | 1.5× | 0.206 / 0.204 | 1.5 |
| email-enron | 0.420 / 0.340 | 0.184 / 0.169 | 2.0× | 0.121 / 0.120 | 1.4 |
| fb15k-237 | 1.45 / 1.44 | 0.289 / 0.206 | 7.0× | 0.144 / 0.143 | 1.4 |
| ss-gleich-usroads-48 | 0.682 / 0.645 | 0.263 / 0.238 | 2.7× | 0.294 / 0.283 | 0.84 |
| dblp | 2.81 / 2.70 | 1.33 / 1.26 | 2.1× | 1.33 / 1.30 | 0.97 |
| yago3-10 | 2.35 / 2.26 | 0.819 / 0.731 | 3.1× | 0.930 / 0.933 | 0.78 |
| hetionet | 3.07 / 2.95 | 2.27 / 2.22 | 1.3× | 1.35 / 1.36 | 1.6 |
| roadnet-tx | 7.73 / 7.91 | 3.27 / 3.80 | 2.1× | 5.32 / 4.99 | 0.76 |
Each cell is cold / warm median in seconds, with ms and µs where marked. The speed-up and ratio columns use the warm medians.
Every level now costs the level below it, not the whole graph. What is
left is level 1: the undirected view, the colouring and the sweeps over
the whole graph, twice by default. On hetionet and cit-hepth that is most
of the time.
GDS was not re-run. Against its warm times in the table above, the new
leiden is faster on seven of the eleven datasets GDS ran. It is slower
on dblp, yago3-10, hetionet and roadnet-tx.
PageRank in 0.8.0 (re-timed against igraph alone)
Section titled “PageRank in 0.8.0 (re-timed against igraph alone)”pagerank-0.8.0/6c918eb/ holds the run: round-N/<engine>/<dataset>.json,
three rounds, one file per dataset for each engine. pg_warren is a release
build of 6c918eb (main with the native pagerank and leiden;
cargo pgrx package, PostgreSQL 16.15, the settings above,
warren.threads = 4; pg_warren.so sha256
a59481d01fe533b8bb9f81663f8b40dbcfb2279efbbb62accb62bb6a8f103bd3). igraph
is python-igraph 1.0.0 (PRPACK, one thread), as above.
Each round ran, per dataset, the two engines one after the other, pg_warren
first in rounds 1 and 3 and igraph first in round 2:
suite.py head-to-head <dataset> --engine <engine> --workload pagerank --threads 4 --extension-profile release,
with the defaults --repeat 5 --budget 120. Nothing else ran on the
machine: the load average at the start of each run was 0.1–1.4, most of it
the runs themselves. Every answer of every round matched the reference
(within 10⁻⁹, each engine under its own convention, as above).
| Dataset | M11 (ae51d77) | 0.8.0 cold | 0.8.0 warm | igraph warm | 0.8.0 / igraph | speed-up on M11 |
|---|---|---|---|---|---|---|
| lesmis | 0.029 | 3.97 ms | 3.13 ms (2.75–3.60) | 29 µs (28–33 µs) | 108 | 9.3× |
| polblogs | 0.035 | 5.84 ms | 4.31 ms (3.76–4.35) | 1.41 ms (1.36–1.59) | 3.1 | 8.1× |
| 0.040 | 0.011 | 6.92 ms (6.77–8.07) | 0.010 (0.010–0.013) | 0.67 | 5.8× | |
| wn18rr | 0.089 | 0.031 | 0.021 (0.020–0.023) | 0.012 (0.012–0.015) | 1.8 | 4.2× |
| cit-hepth | 0.077 | 0.023 | 0.018 (0.018–0.018) | 0.011 (0.011–0.011) | 1.6 | 4.3× |
| email-enron | 0.096 | 0.027 | 0.021 (0.020–0.021) | 0.032 (0.027–0.037) | 0.66 | 4.6× |
| fb15k-237 | 0.069 | 0.078 | 0.011 (0.010–0.012) | 0.012 (0.012–0.014) | 0.90 | 6.4× |
| ss-gleich-usroads-48 | 0.161 | 0.053 | 0.037 (0.036–0.040) | 0.074 (0.073–0.081) | 0.50 | 4.4× |
| dblp | 0.306 | 0.140 | 0.105 (0.101–0.112) | 0.251 (0.239–0.261) | 0.42 | 2.9× |
| yago3-10 | 0.169 | 0.081 | 0.043 (0.039–0.044) | 0.036 (0.035–0.038) | 1.2 | 3.9× |
| hetionet | 0.103 | 0.081 | 0.031 (0.031–0.032) | 0.052 (0.049–0.057) | 0.60 | 3.3× |
| roadnet-tx | 0.998 | 0.363 | 0.224 (0.224–0.225) | 0.810 (0.806–0.819) | 0.28 | 4.4× |
Seconds, with ms and µs where marked. A warm cell is the median of the three rounds’ warm medians, with the lowest and highest of them in brackets (each round’s median is of 5 warm runs); the cold cell is the median of the three cold runs. M11 is pg_warren’s warm median in the main table above (0.7.0’s PL/pgSQL loop). The ratio and speed-up columns use the warm medians.
pg_warren is faster than igraph on 7 of the 12 datasets, by up to 3.6× (roadnet-tx), and the brackets do not overlap on any of them. It is slower on 5:
- lesmis (77 nodes) and polblogs (1,490 nodes): the call’s fixed cost. An SQL statement, the cache lookup and persisting the output vector to the catalog take about 3 ms however small the graph, where igraph answers lesmis in 29 µs. An earlier before-and-after run of the kernel (DESIGN.md §13) measured 1.6 ms on lesmis; this run’s three rounds took 2.75–3.60 ms. The difference was not investigated.
- wn18rr, cit-hepth and yago3-10, by 1.2–1.8×. These are not fixed cost (a few ms of 18–43 ms) and were not profiled. pg_warren runs 20 iterations; PRPACK solves for the fixed point, so the two do different amounts of work, and how much differs by graph.
The ratios move with the graph more than with the run. An engine’s highest round median is within 1.2× of its lowest on 20 of the 24 engine-dataset pairs; the others are pg_warren on lesmis (1.31×) and igraph on wn18rr (1.24×), facebook (1.35×) and email-enron (1.36×).
GDS was not re-run. Against its warm times in the main table above, which
are from the M11 run, the new pagerank is faster on all 12 datasets: on
roadnet-tx 0.22 s against 0.62 s, on yago3-10 0.043 s against 0.146 s.
pgGraph 1.2.1 against pg_warren 0.8.0 and igraph (2026-10-29)
Section titled “pgGraph 1.2.1 against pg_warren 0.8.0 and igraph (2026-10-29)”pgGraph (Apache-2.0, Rust and pgrx,
PostgreSQL 14–18) is the closest project to pg_warren: it also builds a CSR
from your tables and answers graph queries in SQL. This run times it on the
same 12 datasets, in the same scratch cluster, beside pg_warren 0.8.0 and
igraph. pggraph-1.2.1/run/ holds one file per dataset with every workload
for pg_warren and igraph and what pgGraph runs. pggraph-1.2.1/paths/
re-times shortest paths for all three, once igraph’s pairs went through a
fairer call (below). Neo4j and GDS were not re-run: their figures above are
from the M11 run, against 0.7.0.
Provenance
Section titled “Provenance”| pg_warren | 0.8.0, release build of 8dd426c, the release commit that v0.8.0 tags (the tag was not yet on origin when this ran); cargo pgrx package with cargo-pgrx 0.19.3 and Rust 1.97.0 into a private PostgreSQL 16.15 tree; pg_warren.so sha256 b720e538f278899d8846e017d0f2667f71a0163d3552bf8ce48c3ff3af5bfde0; warren.threads = 4 |
| pgGraph | 1.2.1, the latest release (tag v1.2.1, commit 1bbe30149100b49fc4a1494bd3afbeba82070092), built in release mode (its profile: opt-level 3, fat LTO, one codegen unit) with Rust 1.96.0 and cargo-pgrx 0.19.1, as it pins them; cargo pgrx package --no-default-features --features pg16, installed into the same private tree; graph.so sha256 7cb8900debd93f77f8df88fc52bddf21581723432400200d2f110a882e5535ba. head_to_head/pins.json and bench/corpus/README.md have the recipe |
| pgGraph’s settings | Its recommended starting configuration: shared_preload_libraries = 'graph', graph.memory_limit_mb = 4096, persist_on_build and auto_load on, csr_readonly; graph.sync_mode = 'manual', its advice for a static graph; graph.maintenance_memory_mb = 4096. Each session raises the circuit breakers so a whole-graph answer is not cut short: graph.max_nodes and graph.max_frontier 10,000,000 (their maximum), graph.query_work_limit 2,000,000,000 (its maximum), graph.query_memory_mb 4096 (the memory limit). Every setting is in each file (engines.pggraph.describe); string settings at their defaults read empty there |
| The cluster | PostgreSQL 16.15 with the soak’s settings, as in M11 (shared_buffers = 1GB, fsync = off, synchronous_commit = off), plus pgGraph’s preload, which pg_warren’s backends also load |
| igraph | python-igraph 1.0.0 (its C core 1.0.0), CPython 3.13.16, one thread |
| Harness | run/: fe2aed7, suite.py head-to-head <the 12 datasets> --engine pg_warren --engine pggraph --engine igraph --extension-revision 8dd426c --extension-profile release --timeout 300, with the defaults --repeat 5 --budget 120 --threads 4. The tree reads dirty from dblp on: the igraph change below was edited in during the run, after the process had loaded its modules, so that run’s igraph pairs still use distances. paths/: 794b65a, the same command with --workload paths |
| Machine | The M11 VM (4 vCPUs, 15 GiB, no swap, kernel 6.18.44), shared with other agents. One-minute load average at the start of each dataset: 0.5–3.5 for run/, 0.2–1.0 for paths/ (hardware.load_average) |
How pgGraph runs each workload
Section titled “How pgGraph runs each workload”pgGraph’s SQL API (its api-reference)
has traversal, unweighted and weighted shortest paths, connected components,
search, aggregation over traversals and a GQL subset. It has no degree,
PageRank, triangle count, community detection or betweenness, so those
workloads are recorded as unsupported. graph.gql cannot count each node’s
distinct neighbours either: MATCH ... OPTIONAL MATCH is a syntax error in
its subset, and a plain match leaves out nodes with none.
- Loading.
nodesandedgesare loaded by the sameload.sqlas pg_warren’s (ingest). pgGraph reads a relationship table’s primary key as each edge’s identity. The suite’sedgeshas none (a parallel edge repeats its(src, dst)), sokeyadds a surrogatebigintidentity primary key.registerisgraph.add_tableandgraph.add_edge: an undirected graph’s edgesbidirectional(stored both ways), a directed one’s one way, the relation as the edge’s dynamic label, the weight as its weight.buildisgraph.build(), which also writes the persisted artifact (the median of three). The sum of the phases is the time to first query. - Cold and warm. Cold follows
graph.unload_graph, so the call loads the graph from its artifact (graph.auto_load), as pg_warren’s cold followswarren.evict. A query runs on its backend’s one thread; pgGraph has no thread setting. - BFS reach and depths.
count(*)overgraph.traverse(start, depth, direction := 'out', hydrate := false, max_rows := 2147483647), as pg_warren’scount(*)overwarren.bfs.traversetakes a depth bound, and it reserves memory and work for every visited node’s path to that depth. So it has no unbounded search, and a depth bound far beyond the answer’s fails its own resource check.bfs depthspasses the start’s eccentricity from the reference (3–756), the smallest bound that reaches every node. That favours pgGraph: it is told the answer’s depth. - Shortest paths.
max(step)ofgraph.shortest_path(s, t, max_depth := 2147483647, hydrate := false)for each pair, all in one statement as pg_warren’s; on lesmis,max(total_cost)ofgraph.weighted_shortest_path. pgGraph’s weights are integers, and lesmis’s are whole numbers. A depth bound of 20, its default, or of 2³¹−1 timed the same. - Components.
count(DISTINCT component_id)overgraph.connected_components(), a union-find over the whole graph (weak components).
Parallel edges stay in pgGraph’s graph (its “bag semantics”: it returns edge identities), where pg_warren folds them. No workload pgGraph runs can tell the difference, and none did.
Correctness
Section titled “Correctness”Every pgGraph time counted is of an answer that matched the reference
exactly: reach, depths, path lengths and costs, components as partitions.
So did every pg_warren and igraph answer in both runs. Two pgGraph runs did
not finish, both bfs depths on the road networks, where the start’s
eccentricity is 537 (ss-gleich-usroads-48) and 756 (roadnet-tx). traverse
refused them before running (SQLSTATE 54000, PG007): the paths it reserves
for would need 4.5 GB and 68.6 GB, against a 4 GB limit. Raising the limit
past the recommended configuration was not tried; the VM has 15 GB.
Loading (median seconds of each phase; ms and µs where marked)
Section titled “Loading (median seconds of each phase; ms and µs where marked)”| Dataset | Engine | Phases (median seconds) | First query |
|---|---|---|---|
| lesmis | pg_warren | ingest 0.011, build 2.60 ms | 0.014 |
| lesmis | pggraph | ingest 0.012, key 7.18 ms, register 5.26 ms, build 0.041 | 0.066 |
| lesmis | igraph | ingest 373 µs, build 261 µs | 634 µs |
| polblogs | pg_warren | ingest 0.026, build 7.42 ms | 0.034 |
| polblogs | pggraph | ingest 0.024, key 0.016, register 5.06 ms, build 0.123 | 0.168 |
| polblogs | igraph | ingest 5.89 ms, build 8.25 ms | 0.014 |
| pg_warren | ingest 0.050, build 0.028 | 0.078 | |
| pggraph | ingest 0.048, key 0.057, register 5.39 ms, build 0.615 | 0.725 | |
| igraph | ingest 0.028, build 0.017 | 0.044 | |
| wn18rr | pg_warren | ingest 0.147, build 0.075 | 0.222 |
| wn18rr | pggraph | ingest 0.147, key 0.072, register 5.94 ms, build 0.697 | 0.921 |
| wn18rr | igraph | ingest 0.094, build 0.054 | 0.148 |
| cit-hepth | pg_warren | ingest 0.163, build 0.116 | 0.280 |
| cit-hepth | pggraph | ingest 0.165, key 0.239, register 4.73 ms, build 2.43 | 2.84 |
| cit-hepth | igraph | ingest 0.161, build 0.211 | 0.372 |
| email-enron | pg_warren | ingest 0.210, build 0.129 | 0.339 |
| email-enron | pggraph | ingest 0.169, key 0.341, register 5.43 ms, build 4.28 | 4.80 |
| email-enron | igraph | ingest 0.154, build 0.082 | 0.236 |
| fb15k-237 | pg_warren | ingest 0.302, build 0.196 | 0.498 |
| fb15k-237 | pggraph | ingest 0.348, key 0.216, register 4.86 ms, build 2.33 | 2.90 |
| fb15k-237 | igraph | ingest 0.292, build 0.165 | 0.457 |
| ss-gleich-usroads-48 | pg_warren | ingest 0.161, build 0.079 | 0.240 |
| ss-gleich-usroads-48 | pggraph | ingest 0.161, key 0.093, register 5.17 ms, build 2.13 | 2.39 |
| ss-gleich-usroads-48 | igraph | ingest 0.107, build 0.037 | 0.143 |
| dblp | pg_warren | ingest 0.663, build 0.445 | 1.11 |
| dblp | pggraph | ingest 0.642, key 0.526, register 4.81 ms, build 14.77 | 15.94 |
| dblp | igraph | ingest 0.848, build 0.394 | 1.24 |
| yago3-10 | pg_warren | ingest 0.968, build 0.691 | 1.66 |
| yago3-10 | pggraph | ingest 0.849, key 0.678, register 4.80 ms, build 10.68 | 12.22 |
| yago3-10 | igraph | ingest 1.53, build 0.904 | 2.44 |
| hetionet | pg_warren | ingest 1.41, build 1.04 | 2.45 |
| hetionet | pggraph | ingest 1.11, key 1.38, register 5.68 ms, build 20.73 | 23.23 |
| hetionet | igraph | ingest 1.72, build 2.30 | 4.02 |
| roadnet-tx | pg_warren | ingest 2.39, build 1.37 | 3.76 |
| roadnet-tx | pggraph | ingest 3.02, key 2.44, register 5.55 ms, build 72.37 | 77.83 |
| roadnet-tx | igraph | ingest 5.06, build 1.12 | 6.18 |
pgGraph’s time to first query is 0.07–78 s, against pg_warren’s
0.01–3.8 s; nearly all of it is graph.build(), which on roadnet-tx took
72 s (the median of three), against warren.refresh’s 1.4 s.
BFS and components (cold / warm median, seconds; ms and µs where marked)
Section titled “BFS and components (cold / warm median, seconds; ms and µs where marked)”| Dataset | Workload | pg_warren | pggraph | igraph | pggraph / pg_warren |
|---|---|---|---|---|---|
| lesmis | bfs depth 6 | 860 µs / 245 µs | 8.30 ms / 6.21 ms | 12 µs / 4 µs | 25 |
| lesmis | bfs depths | 797 µs / 210 µs | 8.15 ms / 6.11 ms | 16 µs / 11 µs | 29 |
| lesmis | components | 831 µs / 184 µs | 4.87 ms / 2.63 ms | 39 µs / 4 µs | 14 |
| polblogs | bfs depth 6 | 1.37 ms / 574 µs | 0.059 / 0.053 | 99 µs / 43 µs | 92 |
| polblogs | bfs depths | 1.38 ms / 550 µs | 0.059 / 0.054 | 209 µs / 167 µs | 99 |
| polblogs | components | 1.49 ms / 623 µs | 8.77 ms / 3.66 ms | 363 µs / 273 µs | 5.9 |
| bfs depth 6 | 4.33 ms / 1.81 ms | 0.239 / 0.207 | 450 µs / 340 µs | 114 | |
| bfs depths | 3.58 ms / 1.79 ms | 0.240 / 0.209 | 1.00 ms / 953 µs | 117 | |
| components | 3.72 ms / 1.69 ms | 0.042 / 7.08 ms | 858 µs / 92 µs | 4.2 | |
| wn18rr | bfs depth 6 | 0.014 / 2.47 ms | 0.649 / 0.635 | 1.06 ms / 261 µs | 257 |
| wn18rr | bfs depths | 0.014 / 4.97 ms | 1.93 / 1.89 | 4.33 ms / 4.07 ms | 381 |
| wn18rr | components | 0.018 / 9.14 ms | 0.040 / 0.015 | 6.19 ms / 5.34 ms | 1.66 |
| cit-hepth | bfs depth 6 | 7.81 ms / 2.64 ms | 0.786 / 0.708 | 1.45 ms / 589 µs | 268 |
| cit-hepth | bfs depths | 8.39 ms / 4.63 ms | 1.68 / 1.93 | 4.12 ms / 3.88 ms | 418 |
| cit-hepth | components | 0.011 / 6.89 ms | 0.119 / 0.016 | 0.011 / 7.54 ms | 2.3 |
| email-enron | bfs depth 6 | 0.014 / 7.75 ms | 2.28 / 2.26 | 3.29 ms / 2.13 ms | 291 |
| email-enron | bfs depths | 0.020 / 7.89 ms | 2.33 / 2.06 | 5.45 ms / 4.98 ms | 261 |
| email-enron | components | 0.012 / 7.61 ms | 0.215 / 0.025 | 4.14 ms / 2.85 ms | 3.3 |
| fb15k-237 | bfs depth 6 | 0.073 / 4.78 ms | 0.880 / 0.727 | 2.46 ms / 1.14 ms | 152 |
| fb15k-237 | bfs depths | 0.067 / 4.90 ms | 0.825 / 0.727 | 2.58 ms / 2.38 ms | 148 |
| fb15k-237 | components | 0.073 / 4.98 ms | 0.113 / 0.012 | 8.45 ms / 6.18 ms | 2.4 |
| ss-gleich-usroads-48 | bfs depth 6 | 8.69 ms / 263 µs | 0.065 / 8.43 ms | 147 µs / 20 µs | 32 |
| ss-gleich-usroads-48 | bfs depths | 0.041 / 0.035 | error (PG007) | 0.016 / 0.015 | |
| ss-gleich-usroads-48 | components | 0.036 / 0.022 | 0.111 / 0.040 | 0.014 / 2.90 ms | 1.80 |
| dblp | bfs depth 6 | 0.147 / 0.071 | 28.44 / 26.19 | 0.043 / 0.034 | 366 |
| dblp | bfs depths | 0.129 / 0.081 | 29.31 / 28.48 | 0.071 / 0.063 | 351 |
| dblp | components | 0.100 / 0.069 | 0.905 / 0.133 | 0.055 / 7.54 ms | 1.93 |
| yago3-10 | bfs depth 6 | 0.049 / 4.45 ms | 0.991 / 0.506 | 2.16 ms / 932 µs | 114 |
| yago3-10 | bfs depths | 0.042 / 4.95 ms | 1.07 / 0.546 | 0.015 / 0.014 | 110 |
| yago3-10 | components | 0.071 / 0.031 | 0.573 / 0.060 | 0.029 / 0.024 | 1.93 |
| hetionet | bfs depth 6 | 0.067 / 0.015 | 2.81 / 1.51 | 4.77 ms / 3.18 ms | 103 |
| hetionet | bfs depths | 0.064 / 0.015 | 2.85 / 1.53 | 0.011 / 0.010 | 103 |
| hetionet | components | 0.070 / 0.017 | 1.35 / 0.053 | 0.049 / 1.01 ms | 3.2 |
| roadnet-tx | bfs depth 6 | 0.106 / 635 µs | 2.65 / 0.015 | 1.21 ms / 412 µs | 24 |
| roadnet-tx | bfs depths | 0.500 / 0.379 | error (PG007) | 0.217 / 0.211 | |
| roadnet-tx | components | 0.416 / 0.301 | 2.82 / 0.585 | 0.151 / 0.148 | 1.94 |
The last column divides the warm medians.
Shortest paths, 25 pairs in one call (paths/)
Section titled “Shortest paths, 25 pairs in one call (paths/)”| Dataset | pg_warren | pggraph | igraph (get_shortest_path) |
igraph / pg_warren | pggraph / pg_warren | igraph (distances, main run) |
|---|---|---|---|---|---|---|
| lesmis | 1.68 ms / 899 µs | 0.059 / 0.055 | 740 µs / 329 µs | 0.37 | 62 | 454 µs / 331 µs |
| polblogs | 2.58 ms / 1.28 ms | 0.064 / 0.057 | 792 µs / 521 µs | 0.41 | 45 | 3.20 ms / 3.00 ms |
| 5.32 ms / 2.50 ms | 0.098 / 0.065 | 2.60 ms / 2.39 ms | 0.95 | 26 | 0.020 / 0.020 | |
| wn18rr | 0.011 / 1.21 ms | 0.124 / 0.107 | 0.016 / 0.014 | 12 | 89 | 0.087 / 0.081 |
| cit-hepth | 9.92 ms / 5.33 ms | 0.216 / 0.114 | 0.018 / 0.015 | 2.9 | 21 | 0.077 / 0.075 |
| email-enron | 8.05 ms / 2.67 ms | 0.245 / 0.058 | 0.020 / 0.019 | 7.1 | 22 | 0.109 / 0.105 |
| fb15k-237 | 0.066 / 3.65 ms | 0.189 / 0.095 | 0.011 / 9.79 ms | 2.7 | 26 | 0.046 / 0.047 |
| ss-gleich-usroads-48 | 0.168 / 0.145 | 0.403 / 0.348 | 0.100 / 0.099 | 0.68 | 2.4 | 0.254 / 0.251 |
| dblp | 0.043 / 5.81 ms | 0.836 / 0.073 | 0.263 / 0.256 | 44 | 13 | 1.33 / 1.40 |
| yago3-10 | 0.044 / 1.24 ms | 0.572 / 0.107 | 0.019 / 0.017 | 14 | 86 | 0.296 / 0.287 |
| hetionet | 0.053 / 2.47 ms | 1.36 / 0.149 | 0.031 / 0.025 | 10 | 60 | 0.194 / 0.188 |
| roadnet-tx | 1.69 / 1.45 | 7.04 / 5.05 | 1.27 / 1.29 | 0.89 | 3.5 | 3.79 / 3.76 |
Cold / warm median, seconds; the ratios divide the warm medians. The last
column is igraph’s time in run/, before the change below.
igraph’s single-pair call. The M11 run and run/ call igraph’s
distances(source, target) for each pair. In igraph’s C core 1.0.0
(src/paths/unweighted.c) that search does stop once it reaches the
target, but first builds the whole graph’s adjacency list
(igraph_adjlist_init), on every call. get_shortest_path(v, to=...)
(igraph_get_shortest_paths) also stops at the target and reads each
visited node’s edges as it goes (igraph_incident), so it skips that
pass. Weighted, both run Dijkstra over a lazy incidence list and stop at
the target. paths/ uses get_shortest_path with output="epath": the hop
count is the path’s length, a weighted cost its weights’ sum, and an
empty path between two different nodes means there is none. It was 2.5–17×
faster than distances on the unweighted datasets (dblp 0.26 s against
1.40 s) and the same on lesmis, where both run Dijkstra, and its answers
matched the reference on every dataset. The M11 claim that igraph “runs a
whole single-source search for each pair” was wrong: the adjacency list,
not the search, was the cost.
Findings
Section titled “Findings”pgGraph was slower than pg_warren on every workload both run, on every dataset, warm and cold. Warm, by:
- BFS reach and depths: 24–420×. The cost is in pgGraph’s output. Every
row of
traversecarries the node’s path as JSON.path_coordinates_jsonnames each step’s table throughcatalog::validate::relation_name, an SPI query, which gdb samples of a wn18rr run found in the planner. With most of a graph returned, that is 45–110 µs a row. pgGraph documents traversal for bounded neighbourhoods (“Bounded BFS/DFS with depth, node, and frontier circuit breakers”), not for whole-graph sweeps. - Shortest paths: 2.4–89×. A pgGraph call costs about 2.5 ms even on lesmis, the same for any depth bound, and 25 calls cost 55 ms there. Its closest results are on the road networks: ss-gleich-usroads-48 (0.35 s against 0.14 s) and roadnet-tx (5.1 s against 1.45 s).
- Components: 1.7–14×; closest on wn18rr (15 ms against 9.1 ms), ss-gleich-usroads-48 (40 ms against 22 ms), dblp, yago3-10 and roadnet-tx (1.9×).
- Cold: pgGraph’s first call loads the artifact; pg_warren’s reads its segments. pgGraph’s cold times were 1.5–230× pg_warren’s.
- Time to first query: 5–21× (above).
Where pgGraph wins. Against igraph, on shortest paths on dblp: 73 ms
against 0.26 s, since igraph’s search covers much of a 317,080-node graph
for each pair and pgGraph’s, like pg_warren’s, does not. Against igraph’s
slower distances call in run/, pgGraph was also faster on email-enron,
yago3-10 and hetionet; that call is no longer the one compared. Nowhere
else, and nowhere against pg_warren.
Shortest paths against igraph’s fairer call. pg_warren runs a
bidirectional BFS for each pair, all 25 in one SQL call. igraph’s
get_shortest_path searches from one end and stops at the target.
pg_warren was faster on 7 of 12 datasets, by 2.7–44× (dblp 5.8 ms
against 0.26 s, yago3-10 1.2 ms against 17 ms). igraph was faster on 5:
lesmis (0.33 ms against 0.90 ms) and polblogs (0.52 ms against 1.28 ms),
where one SQL call’s fixed cost dominates; facebook, within 5%
(2.39 ms against 2.50 ms); and the road networks, ss-gleich-usroads-48
(99 ms against 145 ms) and roadnet-tx (1.29 s against 1.45 s), whose
paths run to hundreds of hops. The road networks were not profiled.
Plain Cypher’s shortestPath, the like-for-like algorithm (it also
searches from both ends), was 2.6–23× slower than pg_warren warm in the
M11 run on the 11 datasets it ran (dblp 26 ms against 6.0 ms,
ss-gleich-usroads-48 0.33 s against 0.13 s).
pg_warren 0.8.0 against igraph elsewhere in run/. As in M11, igraph
was faster on BFS reach on every dataset (1.5–66×), and on BFS depths and
components on all but three cells: yago3-10’s depths (4.9 ms against
14 ms), and cit-hepth’s and fb15k-237’s components.