Speedup Case Study: Cypher + PageRank, GFQL vs Neo4j + GDS#
This case study runs one three-stage graph pipeline, filter, PageRank, filter,
on two systems. GFQL is Graphistry’s open-source graph query language: Cypher
that executes in-process on Python dataframes with no database. Neo4j + Graph
Data Science (GDS) is the graph database and its analytics library. On both
graphs, Twitter (2.4M edges) and GPlus (30M edges), GFQL on CPU finished the
pipeline faster than Neo4j + GDS while selecting the same nodes. On GPlus the
GFQL CPU path takes 34.11 s and Neo4j + GDS
354.47 s. On Twitter the GFQL GPU path is
11.3x faster than the CPU path
(0.23 s versus 2.62 s).
On GPlus the GPU path takes 2.47 s (diagnostic), but its
cuGraph PageRank selects a different node set than igraph at the 0.9995
cutoff, so that time is a diagnostic and no GPU-vs-CPU ratio is published
for GPlus.
Neo4j + GDS |
GFQL Cypher (CPU) |
GFQL Cypher (GPU) |
GFQL GPU vs CPU |
|
|---|---|---|---|---|
Twitter (81,306 nodes / 2.4M edges) |
|
|
|
|
GPlus (107,614 nodes / 30M edges) |
|
|
|
not published (selection differs) |
The pipeline#
A three-phase graph pipeline: filter, run PageRank, filter again. The query is
standard Cypher extended with GFQL’s graph pipeline syntax. Each GRAPH { }
block takes a graph in and passes a graph on.
# pip install graphistry
result = g.gfql("""
GRAPH g1 = GRAPH {
MATCH (n)-[e]-(m)
WHERE n.degree >= $degree_cutoff
}
GRAPH g2 = GRAPH {
USE g1
CALL graphistry.cugraph.pagerank.write()
}
GRAPH {
USE g2
MATCH (n)-[e]-(m)
WHERE n.pagerank >= $pagerank_cutoff
}
""",
params={
"degree_cutoff": degree_cutoff,
"pagerank_cutoff": pagerank_cutoff,
},
engine="cudf", # or "pandas" with igraph backend
)
GRAPH g1: keep high-degree nodes and their neighbors.GRAPH g2: add PageRank scores tog1(igraph on CPU, cugraph on GPU).Final
GRAPH: keep high-PageRank nodes and their neighbors.
The query does not change between engines:
CPU:
engine="pandas",backend="igraph"GPU:
engine="cudf",backend="cugraph"
Intermediate graphs stay in Arrow, pandas, or cuDF memory in the same Python process. GFQL returns the same result on every engine or rejects the query before execution; see Choosing a GFQL Engine: pandas, Polars, cuDF, Polars-GPU for the parity rules.
Neo4j + GDS analog#
The Neo4j version writes marker properties at each stage and projects a separate in-memory graph for GDS:
// 1. Mark seed nodes by degree
MATCH (n:Node)
SET n.seed = n.degree >= $cutoff;
// 2. Expand one hop from seeds
UNWIND $seed_ids AS sid
MATCH (s:Node) WHERE id(s) = sid
MATCH (s)-[r:LINK]-(target:Node)
SET target.in_subgraph = true, r.in_subgraph = true;
// 3. Project subgraph and run PageRank
CALL gds.graph.project.cypher(
'subgraph',
'MATCH (n:Node) WHERE n.in_subgraph RETURN id(n) AS id',
'MATCH (a)-[r:LINK]->(b) WHERE r.in_subgraph
RETURN id(a) AS source, id(b) AS target
UNION ALL
MATCH (a)-[r:LINK]->(b) WHERE r.in_subgraph
RETURN id(b) AS source, id(a) AS target'
);
CALL gds.pageRank.write('subgraph', {writeProperty: 'pagerank'});
// 4. Keep high-PageRank core + one hop
MATCH (n:Node) WHERE n.pagerank >= $cutoff
SET n.core = true;
UNWIND $core_ids AS cid
MATCH (c:Node) WHERE id(c) = cid
MATCH (c)-[r:LINK]-(target:Node)
SET target.final = true, r.final = true;
Method and limits#
Workload: one pipeline (filter, PageRank, filter) on two SNAP graphs. Selected-node parity is measured (Jaccard, gate 0.95): on Twitter the GFQL CPU arm matches Neo4j at 0.9999; on GPlus the GFQL CPU arm selects exactly the locked lane’s set; the GPlus GPU arm is at 0.91 against CPU and is therefore diagnostic-only (see the caveats below).
Timing: warm runs after warm-up (2 warm-ups, 5 timed runs, median). The GFQL arms were measured at the release commit named in the Measurement block; the Twitter Neo4j arm is the 2026-07-28 measurement, and the GPlus Neo4j time comes from a later locked run of twelve position-balanced slots on one machine. The Measurement block below records every run.
Profiles differ: GFQL reuses frames already resident in Python. Neo4j includes server round trips, writes marker properties in both filter stages, and rebuilds the GDS in-memory projection on every timed iteration. The Neo4j column is therefore a direct pipeline time, not an engine-primitive time. The page states which system finished first but publishes no GFQL-vs-Neo4j ratio.
Comparable ratio: the GPU-vs-CPU column compares the same GFQL query and the same profile, so that ratio is published.
Scope: for the four-engine CPU/GPU comparison and engine choice, see Choosing a GFQL Engine: pandas, Polars, cuDF, Polars-GPU. For seeded lookups, see Adjacency Index: Fast Lookups from Known Nodes. For the Spark GraphFrames comparison, see GFQL vs Apache Spark GraphFrames on One Node.
Provenance#
Every figure on this page is printed from docs/source/_data/gfql_benchmarks.json,
which pyg-bench publishes.
Measurement
- Measured:
2026-09-04 and 2026-08-30
- Host:
2026-09-04: dgx-spark (NVIDIA GB10), 20 CPU
2026-08-30: dgx-spark (NVIDIA GB10, driver 580.173.02), 20 CPU
- Repetitions:
2026-09-04: GFQL arms: graph loaded once, then 2 warmups + 5 timed runs per arm on the resident graph, median; Twitter Neo4j + GDS arm carried over unchanged from the 2026-07-28 lane (2 warmups + 5 timed runs)
2026-08-30: 12 position-balanced slots, six per arm; each slot 2 warmups + 11 timed runs; median of slot medians
- Runtime:
2026-09-04: RAPIDS 26.02 (NVIDIA nvcr.io/nvidia/rapidsai/base:26.02-cuda12-py3.13 image, Python 3.13) with python-igraph 1.0.0; cuDF 26.2.1, cuGraph 26.2.0, pandas 2.3.3; PageRank contract pinned (undirected, damping 0.85, cuGraph max_iter 100 / tol 1e-6); Twitter Neo4j 2026.02.2 + graph-data-science in Docker on the same host (2026-07-28); benchmark image graphistry/test-rapids-official:26.02-gfql-polars
2026-08-30: RAPIDS 26.02 (NVIDIA nvcr.io/nvidia/rapidsai/base:26.02-cuda12-py3.13 image, Python 3.13); GFQL Python 3.13.12, pandas 2.3.3, python-igraph 1.0.0; Neo4j 2026.02.2 + graph-data-science with Python client 3.12.3 on the same host; benchmark image graphistry/test-rapids-official:26.02-gfql-polars
- Dataset:
2026-09-04: SNAP twitter_combined (81,306 nodes / 2,420,766 edges) and gplus_combined (107,614 nodes / 30,494,866 edges); sha256 of each source file is recorded in the arm artifacts
2026-08-30: SNAP gplus_combined (107,614 nodes / 30,494,866 edges; 398,930,514 bytes; sha256 492b7a63ec7816cac6aa0466be8521a0528cb1df2ca7f0b554ad746adc6bbbee)
- Result validation:
2026-09-04: Every arm records the node id set its pipeline selected, captured outside the timed region. Comparability is the Jaccard index of those sets against a 0.95 threshold declared before the run; values are in summary.json (Twitter CPU/Neo4j 0.9999).
2026-08-30: All 12 selected-node sets had Jaccard 1.0 against the reference (gate 0.95); every slot passed typed result, exact run-contract, and load/self-spike validation; the committed aggregate exactly recomputes from all slots.
- Caveats:
The Neo4j arm writes marker properties during both filter stages and rebuilds its GDS projection per timed iteration; the paired GFQL arm retains resident frames and writes nothing. Twelve position-balanced slots (six per arm) produced exact selected-node parity, but the measurement profiles differ, so this is a direct pipeline time and no GFQL-vs-Neo4j ratio is published.
Its selected-node set differs from the GFQL CPU (pandas + igraph) arm’s on gplus (Jaccard 0.91065, gate 0.95); this is a direct pipeline time, not a comparison.
In the Neo4j arm each filter stage writes a marker property onto every matched node and relationship, 32 seeds per batch, and GDS scores an explicitly symmetrised projection; the GDS in-memory projection is rebuilt per timed iteration while the GFQL arms retain their resident frames. The GFQL arms materialise a subgraph and write nothing. The figure is a pipeline time, not an engine-primitive time.
The Twitter Neo4j + GDS arm was measured on 2026-07-28 and is reused unchanged; the GFQL arms were re-measured on 2026-09-04 at PyGraphistry 0.59.0 under the pinned PageRank contract.