Speedup Case Study: Cypher + PageRank, GFQL vs Neo4j + GDS#
Real graph work is usually a pipeline, not one query: select a subgraph, run an algorithm over it, then select from the result. This case study times that shape, filter then PageRank then filter, against Neo4j + Graph Data Science.
GFQL turns minutes into seconds, and seconds into subseconds. On GPlus (30M edges)
the pipeline drops from 354.47 s on Neo4j + GDS to
34.11 s on CPU. On Twitter (2.4M edges) it drops from
11.72 s to 2.62 s on CPU, and
to 0.23 s on GPU. Both systems select the same nodes.
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.
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 agreement is measured (Jaccard, threshold 0.95): on Twitter the GFQL CPU arm matches Neo4j at 0.9999; on GPlus the GFQL CPU arm selects exactly the reference run’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 queries from known nodes, see When indexes help. 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
- Notes:
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.
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.
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.