Choosing a GFQL Engine: pandas, Polars, cuDF, Polars-GPU#
GFQL runs the same query on four interchangeable execution engines. You pick
the engine with one keyword — engine=, accepted uniformly by g.gfql() and
g.hop() — and GFQL returns identical results on every one (differential parity
is a release gate). Unsupported engine/query combinations are declined during
validation, compilation, or planning before query execution whenever they can be
known statically, so the safety contract is same answer or pre-execution error,
not silent fallback. Pick the engine that fits your hardware and workload; nothing
else changes.
Note
New to GFQL? This page assumes you already have a graph g and a query. If not,
build one first — see 10 Minutes to GFQL (10 Minutes to GFQL).
The one-line speedup#
On real graphs, switching the default pandas engine to the columnar Polars
engine is a one-keyword change — no GPU, same results:
import graphistry
g = graphistry.edges(df, 'src', 'dst') # df: your edges dataframe (pandas / Polars / cuDF)
query = "MATCH (a)-[e]->(b) RETURN b" # any GFQL / Cypher query
g.gfql(query) # engine='pandas' (default)
g.gfql(query, engine='polars') # often much faster on query-heavy workloads, identical results
On the prrao87/graph-benchmark q1–q9 Cypher suite, Polars beats pandas on all nine
queries at both graph sizes measured, and by more on the larger one. The per-query
numbers are on the GFQL Performance: Vectorization and GPU Acceleration page.
Your existing pandas, Polars, or cuDF graph works as-is: the input frames are accepted and
coerced once; the only change is the keyword. The catch: a few exotic Cypher features still
require engine='pandas' (they decline during validation, compilation, or planning rather
than silently bridge), and the GPU engines only pay off on larger work. On CPU,
Polars wins the common graph-query shapes (traversal,
WHERE/ORDER, aggregation) — see When not to use Polars below.
Note
Already a Polars user? The default now keeps you native. With the default
engine='auto', a graph whose bound frames are all polars.DataFrame runs on the
Polars engine and returns Polars frames. If the query uses a shape the Polars engine
declines, GFQL falls back to pandas for that call — so auto is native when it can
be, and pandas otherwise. Pass engine='polars' explicitly when you want a decline
to raise instead of silently falling back:
import polars as pl, graphistry
g = graphistry.edges(edges_pl, 'src', 'dst').nodes(nodes_pl, 'id') # polars frames
out = g.gfql(query) # auto -> native Polars (out._nodes is polars)
out = g.gfql(query, engine='polars') # same, but a declined shape raises
Note
Result frames match the engine. With engine='polars' or 'polars-gpu' the
output is Polars — result._nodes and result._edges are polars.DataFrame (and
cudf.DataFrame for engine='cudf'). If downstream code is pandas-specific (.iloc,
.loc, groupby().apply()), call result._nodes.to_pandas() to convert back.
The four engines#
Engine |
Hardware |
Frame type |
Opt-in? |
In one line |
|---|---|---|---|---|
|
CPU |
|
default |
Universal default; best on small/interactive graphs. |
|
CPU |
|
explicit |
Columnar + fused lazy plan; the CPU speed win, no GPU needed. |
|
NVIDIA GPU |
|
explicit |
RAPIDS GPU, eager op-by-op; great for one very large materialization. |
|
NVIDIA GPU |
|
explicit |
The Polars fused plan executed on GPU (cudf_polars); fastest on heavy multi-hop. |
engine='auto' follows the input frames: Polars frames run on polars, cuDF frames on
cudf, everything else on pandas. Two AUTO fast paths go further — all-Polars frames
are tried on polars, and all-cuDF frames are tried on polars-gpu when a GPU collect
probes usable — each falling back to pandas / cudf respectively if the query uses a
shape that engine declines. Passing the engine explicitly turns those declines into errors
instead of a fallback (see What auto does below).
How the engines compare#
Each engine has a shape it is built for:
Polars-CPU is the everyday win. It beats pandas on all nine queries of the q1–q9 Cypher suite at both sizes measured (GFQL Performance: Vectorization and GPU Acceleration), with no GPU, because it builds one fused lazy plan and collects once instead of materializing an intermediate per operation.
Polars-GPU runs that same fused plan on the GPU. It pays off once a step carries enough work to amortize a kernel launch: large frontiers, dense joins, full-graph aggregation.
cuDF executes eagerly, op by op. That suits one very large materialization where a single join dominates the query and the in-memory Polars-GPU executor comes under memory pressure.
pandas carries no plan overhead, so it stays the right default for trivially small operations and the widest-compatibility path.
Seeded / selective lookups are an indexing problem, not an engine race: the opt-in resident index turns the
O(E)scan into anO(degree)gather on every engine, so the cost tracks the seeds rather than the graph — see [F5] below and Seeded Traversal Indexes (CSR Adjacency).
Coming from another graph tool#
GFQL is dataframe-native: pip install, then query the pandas, Polars, or cuDF frame
you already have, in your own process. There is no server to stand up, no ETL to load, no
projection step, no cluster to size. The query, the analytic, and the scoring stay in one
pipeline over one set of frames.
The table says what you get when the query runs there instead of somewhere else. The measured comparison, with its lane and its provenance, is on the GFQL Performance: Vectorization and GPU Acceleration page.
Coming from |
Written in Cypher today? |
What GFQL gives you |
|---|---|---|
Neo4j + GDS |
Yes — GFQL accepts the same |
Filter → PageRank → filter runs as one in-process call: no graph projection, no write-back, no round trip. The pipeline and its reproducer are in GFQL Cypher Benchmark: CPU/GPU DataFrames vs Neo4j. |
Kuzu |
Yes. |
The measured q1–q9 board is against embedded Kuzu; see The q1–q9 board: GFQL vs an embedded graph database for the lane, the per-query numbers, and the losses. The GFQL side queries a frame that is already in memory — nothing to load, nothing to index first. |
LadybugDB |
Yes. |
The same dataframe-native path: in-process, GPU-capable, no separate store. Polars
streaming ( |
networkx |
No — GFQL adds a declarative query language over the same graph. |
Columnar CPU execution and a one-keyword move to GPU, on frames rather than Python objects. |
igraph |
No. |
igraph is the CPU PageRank backend inside GFQL, so you keep it and gain the query layer, the Polars engines, and the GPU path. |
Spark GraphFrames |
No — GFQL is Cypher; GraphFrames is a DataFrame API. |
Single-node execution with interactive latency and no cluster to provision or tune. The measured head-to-head, with its committed raw results, is in GFQL Graph Benchmark: DataFrame-Native vs Apache Spark GraphFrames. |
PuppyGraph |
Yes. |
GPU and CPU graph analytics — PageRank, centrality, community — on the pulled subgraph, in the same pipeline as the query. |
Route by shape: selective seeded lookups favor the GFQL resident index, scan and aggregate volume favors Polars, and bulk frontier expansion and full pipelines favor Polars or a GPU engine.
What is not benchmarked: motif, triangle, and other cyclic multi-way-join patterns. They run in GFQL, and we publish no performance claim about them.
Decision matrix#
Workload shape |
Size (edges) |
Hardware |
Recommended engine |
Notes |
|---|---|---|---|---|
Filter / |
past small/interactive |
CPU |
|
takes over past small graphs; gap grows with size [F1] |
Bulk 1-hop frontier expansion |
past small/interactive |
CPU |
|
takes over past small graphs; gap grows with size [F1] |
Heavy multi-hop (2-hop+) |
large |
GPU |
|
fastest until extreme materialization [F3]; GPU-or-error [F4] |
Full-graph aggregation |
very large |
GPU |
|
GPU work-bound [F2] |
One very large single materialization |
huge output row count |
GPU |
|
Polars-GPU can hit memory pressure here [F3] |
Trivially small op (bare equality filter) |
any |
CPU |
|
boolean mask beats Polars plan overhead; immaterial [F1] |
Selective / seeded traversal |
any |
CPU |
|
O(degree), not an engine choice [F5] |
[F1] Polars leads on CPU, and by more as the graph grows. On the q1–q9 Cypher suite it beats pandas on all nine queries at both sizes measured, and the pandas-to-Polars gap is wider on the larger graph for every query (GFQL Performance: Vectorization and GPU Acceleration). Pandas only edges out on a trivially small operation (a bare equality mask), where the absolute difference is immaterial. The real small-size floor is GPU-only — cuDF / Polars-GPU need enough work to amortize kernel launch ([F2]).
[F2] GPU is work-bound, not size-bound. A GPU wins when there is enough work to amortize its millisecond-scale kernel-launch floor: big frontiers, dense joins, full-graph aggregation. Tiny or seeded work finishes faster on CPU.
[F3] Polars-GPU memory pressure. On an extreme single materialization (a huge output
row count from one join) raw cudf leads and polars-gpu slips as its in-memory
GPU executor comes under memory pressure. Prefer cudf for that regime.
[F4] Polars-GPU is GPU-or-error. It never silently falls back to CPU and reports the result as a GPU run (see Honesty below).
[F5] Selective traversal is an indexing problem, not an engine choice. A seeded hop
from a few nodes is fastest with the opt-in CSR adjacency index (g.gfql_index_all() /
g.create_index(...), index_policy=), which turns the O(E) scan into an O(degree)
gather — a complexity-class change, so the cost tracks the seeds’ neighborhood rather than
the graph (index routing for the native seeded g.hop() currently engages on pandas, not
yet the Polars hop path). It works on all four engines, but seeded work is so small that
CPU wins — the gather is tiny work on pandas/Polars, below the GPU kernel-launch floor
on cuDF — the clean inverse of bulk, where the GPU pulls ahead. So pick the index for selective
traversal and a CPU engine to drive it. See Seeded Traversal Indexes (CSR Adjacency) for the full guide.
Switching engines#
The engine is a single keyword on g.gfql() (and g.hop()). The graph and
the query never change — only engine= does, and the answer stays identical
(or the compiler/planner declines the unsupported engine before execution rather
than silently changing it).
import graphistry
g = graphistry.edges(df, 'src', 'dst') # your existing graph (any frame type)
query = "MATCH (a)-[e]->(b) RETURN b" # any GFQL / Cypher query
g.gfql(query) # engine='pandas' (default)
g.gfql(query, engine='polars') # CPU columnar, no GPU, identical results
g.gfql(query, engine='cudf') # NVIDIA GPU (RAPIDS)
g.gfql(query, engine='polars-gpu') # same fused plan on GPU
Getting results back as pandas#
The result’s ._nodes / ._edges come back in the engine’s frame type: a
polars.DataFrame for 'polars' / 'polars-gpu', a cudf.DataFrame
for 'cudf'. When downstream code is pandas-only (matplotlib, scikit-learn,
.iloc / groupby().apply()), convert once with .to_pandas():
out = g.gfql(query, engine='polars') # or 'cudf' / 'polars-gpu'
nodes_pd = out._nodes.to_pandas() # -> pandas for matplotlib / sklearn / ...
nodes_pd.plot.scatter(x='x', y='y') # pandas-only downstream code, unchanged
Mixing engines#
The build frame type and the run engine are independent — GFQL coerces the input
frames to the engine you ask for. A pandas graph runs on engine='polars', a
Polars graph runs on engine='pandas', and so on. The only cost is a
one-time convert of the input frames at the start of the call; the query then
runs fully on the chosen engine. Note that engine='auto' (the default) follows
the input frames — Polars frames run natively on polars, cuDF frames on
cudf (or polars-gpu when that GPU path probes usable), everything else on
pandas — falling back to pandas / cudf only for query shapes the native
engine declines.
Tip
For selective, seeded traversal, build the CSR adjacency index once with
g.gfql_index_all() (or index_policy=) — it works on all four engines
and turns the O(E) scan into an O(degree) gather. An AUTO build on Polars frames now
keeps them native, so g.gfql_index_all() and g.gfql_index_all(engine='polars')
build the same index. See Seeded Traversal Indexes (CSR Adjacency).
Analytics under Polars (umap / hypergraph / compute_cugraph …)#
A GFQL call() that runs a whole-graph analytic — umap, hypergraph,
compute_cugraph / compute_igraph, the *_layout ops, collapse — has
no native Polars implementation (these wrap pandas / cuDF / GPU libraries and
always will). Under engine='polars' / 'polars-gpu' GFQL runs them as a
mode-gated, off-engine modality switch rather than declining outright:
``call_mode=’auto’`` (the default): the analytic runs off-engine — on pandas for
polars, on cuDF (on device) forpolars-gpu— and its result is coerced back to Polars losslessly (via Arrow). A one-timeRuntimeWarningper analytic notes the off-engine run.polars-gpuis GPU-or-error: it bridges to cuDF and declines if the GPU/cuDF stack is missing (it never silently drops a GPU analytic to host pandas).``call_mode=’strict’``: decline before running the analytic instead of bridging — for benchmark integrity (no hidden modality switch attributed to the Polars engine) or a hard memory ceiling.
Note
Memory on a very large graph. The bridge materializes a copy of the graph in
the off-engine format — pandas (host) for polars, cuDF (device / unified
memory) for polars-gpu. That transient copy is the same allocation you’d
incur running the analytic on engine='cudf' directly, so GFQL does not add
a per-call size cap (a row count is a poor memory proxy, and the real cap belongs
at the RMM / container / deployment layer). For a graph large enough that the copy
is a concern, either set call_mode='strict' (decline the bridge) or run the
analytic under an RMM device-memory limit / container memory limit, exactly as you
would for any cuDF workload.
This is deliberately narrower than traversal / filter / row ops (hop,
WHERE, RETURN …), which stay parity-or-static-decline and are never
bridged — a bridge there would hide a missing native impl and misreport pandas
performance as Polars. Set the mode from Python or the environment (live, Python
override > env > default):
from graphistry.compute.gfql.lazy import set_call_mode, CALL_MODES # ('auto', 'strict')
set_call_mode('strict') # decline off-engine analytics (pass None to reset to env/default)
# or: export GFQL_POLARS_CALL_MODE=strict
cuDF vs Polars-GPU#
Both run on an NVIDIA GPU, so which do you use?
cuDF is not deprecated. It remains a first-class, supported engine and is the right choice for one very large materialization (footnote F3).
They execute differently.
cudfruns GFQL eagerly — each hop is a separate kernel launch with a materialized intermediate.polars-gpuruns the same fused lazy plan as the CPU Polars engine, collected once on the GPU. Fusing the plan is whypolars-gpuleads on heavy multi-hop and why even CPU Polars often beats eager cuDF on bulk work.Frame type.
cudfoperates oncudf.DataFrame;polars-gpuoperates onpolars.DataFrame(only the lazy.collect()runs on the GPU). Either way, a graph built from pandas frames is accepted and coerced for you — only the keyword changes.Install.
cudfandpolars-gpuboth need the RAPIDS GPU stack;polars-gpuadditionally usescudf_polars.polars(CPU) only needspip install polars.
Larger-than-memory: streaming execution#
The default Polars engines run in-memory: fastest and most stable while the graph and its query intermediates fit in RAM (or device memory). When a query’s intermediates would blow past memory — a wide multi-hop frontier, a large join, a big aggregation — GFQL has two opt-in streaming modes that trade a little latency for a much larger working set:
Mode |
Engine |
What it does |
|---|---|---|
|
|
Collects the fused plan with Polars’ streaming engine — processes in batches and spills to disk, so intermediates can exceed RAM. |
|
|
Uses the cudf-polars streaming executor — the escape hatch for results larger than device memory (the default in-memory executor would OOM). |
Both are off by default on purpose: they add overhead that mildly regresses small/interactive work, and for the in-memory regime the benchmarks measure, the default is faster and more stable. Results are parity-identical to the default — streaming changes how the plan runs, not what it returns.
Set them by environment variable:
# CPU: batched + disk-spill for larger-than-RAM intermediates
export GFQL_POLARS_CPU_STREAMING=1
# GPU: streaming executor for larger-than-device-memory results
export GFQL_POLARS_GPU_EXECUTOR=streaming
…or from Python at runtime — the setting is read live (per collect), and a Python override takes precedence over the environment variable:
from graphistry.compute.gfql.lazy import (
set_cpu_streaming, set_gpu_executor, GPU_EXECUTORS,
)
set_cpu_streaming(True) # CPU streaming collect (pass None to reset to env/default)
set_gpu_executor('streaming') # one of GPU_EXECUTORS == ('in-memory', 'streaming')
Then use engine='polars' / engine='polars-gpu' exactly as before — no code
change:
import graphistry # env vars above must be set first
g = graphistry.edges(edges_df, 'src', 'dst')
result = g.gfql(query, engine='polars') # streaming collect (CPU, disk-spill)
# result = g.gfql(query, engine='polars-gpu') # streaming executor (GPU)
Note
What streaming does and does not cover today. These flags stream the
query (collect), which helps when the input fits but the intermediates or
result do not. They do not yet give out-of-core input: graphistry
currently materializes edge/node frames at ingestion (a passed
polars.LazyFrame is collected immediately), so the source graph must still
fit in memory. True out-of-core-from-disk — building GFQL directly on a lazy
pl.scan_parquet source so a graph larger than RAM never fully materializes —
is work in progress; see the Friendster (~1.8B edges) discussion in the
GraphFrames benchmark page.
When not to use Polars#
Three cases, stated so you can route around them:
Trivially small operations (a bare node-equality filter): pandas’ boolean mask beats Polars’ plan overhead, and in absolute terms it is immaterial. For traversal /
WHERE/ORDER/ aggregation, Polars leads on CPU (footnote F1). The real small-size caveat is GPU-only (cuDF / Polars-GPU need larger work — footnote F2).A few exotic Cypher features are not yet native on Polars (e.g. cross-entity same-path
WHERE, some temporal/entity-text forms). GFQL rejects those shapes during validation, compilation, or planning before query execution and points atengine='pandas'— it never silently bridges Polars to pandas, because that would misreport pandas performance as Polars (see Honesty).One extreme materialization (a huge output row count): prefer
cudfoverpolars-gpu(footnote F3).
Parity and honesty#
Identical results across engines. Differential parity — every engine’s output must match the pandas oracle — is a release gate, exercised across forward/reverse/undirected, 1-3 hop, filters, and aggregations.
No silent fallback for traversal / filter / row ops — parity-verified. For
hop/WHERE/RETURN/ aggregation, the Polars engine runs natively or the query is declined before execution during validation, compilation, or planning. For string GFQL / Cypher queries, known unsupported syntax and unsupported lowering shapes are rejected by the compiler/validator before execution starts; Python-built ASTs hit the same safety boundary in the local planner before the unsupported engine path runs. GFQL never quietly converts to pandas, so a traversal latency you measure is real work on the engine you asked for.polars-gpuis GPU-or-error: if any step of the plan cannot run on the GPU, the plan is rejected rather than silently running on CPU and labelling it a GPU result.Whole-graph analytics are the one mode-gated exception.
umap/hypergraph/compute_cugraphand friends have no Polars kernel; undercall_mode='auto'(default) they run off-engine and warn once (see Analytics under Polars). This is not silent — it warns — andcall_mode='strict'restores strict parity-or-pre-execution-decline for benchmark integrity, so a benchmarked run can guarantee no hidden modality switch.
Methodology#
Hosts, datasets, warm-median protocol, cross-engine result validation, provenance, and reproducers live with the numbers on the GFQL Performance: Vectorization and GPU Acceleration page. A figure that cannot be traced to a committed benchmark artifact is not published.
Install#
pip install graphistry # base; pandas engine works out of the box
pip install graphistry polars # adds the CPU 'polars' engine
# 'cudf' and 'polars-gpu' require the NVIDIA RAPIDS stack (GPU);
# 'polars-gpu' additionally uses cudf_polars.
Then change one keyword — your existing graph and query are unchanged:
import graphistry
g = graphistry.edges(df, 'src', 'dst') # your existing pandas, Polars, or cuDF graph
g.gfql("MATCH (a)-[e]->(b) RETURN b", engine='polars') # CPU columnar
g.gfql("MATCH (a)-[e]->(b) RETURN b", engine='polars-gpu') # same plan on GPU
What auto does#
auto prefers the native engine for your frames and keeps a safety net. A few exotic
Cypher features still require engine='pandas': the Polars engine declines them before
execution rather than silently bridging. Under auto that decline is caught and the
call is re-served on pandas (all-cuDF frames decline back to cudf), so a query that
works today keeps working while everything the native engine does support stays native.
Pass the engine explicitly when you would rather know: engine='polars' /
'polars-gpu' raise NotImplementedError on a declined shape instead of falling back,
which is what you want in a benchmark or a pipeline that must not silently change engines.
engine='polars-gpu' is additionally GPU-or-error and never quietly runs on CPU.
Performance is rarely the downside — CPU Polars wins common graph queries past small/interactive sizes; only trivially small operations favor pandas, immaterially.
Note
Non-GFQL surfaces (layouts, plotting, featurization) still consume Polars frames as an
input format and compute in pandas, so auto coerces there. The native-under-auto
behavior described above is specific to GFQL query execution.
See also#
GFQL Performance: Vectorization and GPU Acceleration — GFQL performance overview
GFQL Cypher Benchmark: CPU/GPU DataFrames vs Neo4j — end-to-end CPU/GPU vs Neo4j+GDS
GFQL API Reference — GFQL API reference
GFQL Remote Mode — run GFQL on a remote GPU