Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
115 commits
Select commit Hold shift + click to select a range
21ca7da
feat(gfql/polars): native lazy Polars engine, reconciled onto #1656 s…
lmeyerov Jun 27, 2026
d485b54
perf(gfql/polars): chain combine as one lazy collect-once over materi…
lmeyerov Jun 27, 2026
df82ebf
perf(gfql/polars): node-only MATCH fast path (skip traversal machinery)
lmeyerov Jun 27, 2026
f803534
perf(gfql/polars): unconstrained 1-hop fast path (all edges + endpoints)
lmeyerov Jun 27, 2026
b8f1365
perf(gfql/polars): generalize fast path to filtered single-hop
lmeyerov Jun 27, 2026
b03f1e3
perf(gfql/polars-gpu): use cudf-polars in-memory executor (stable + f…
lmeyerov Jun 27, 2026
4f8916e
docs(gfql): regression-guard the absent-entity text fallback (#1650)
lmeyerov Jun 27, 2026
7bc6cb3
fix(gfql/polars): UNION DISTINCT crash — engine-aware df_unique (not …
lmeyerov Jun 27, 2026
f528e16
fix(gfql/polars): 3-valued boolean over null literals (AND/OR/NOT cas…
lmeyerov Jun 27, 2026
65cab86
fix(gfql/polars): honest NIE on heterogeneous (mixed-type) columns, n…
lmeyerov Jun 27, 2026
e631bff
fix(gfql/polars): honest NIE on temporal-constructor-string property …
lmeyerov Jun 27, 2026
20dffc4
fix(gfql/polars): honest NIE on temporal arithmetic (duration literal…
lmeyerov Jun 27, 2026
1fb2880
fix(gfql/polars): remove predicate pandas-bridge (NO-CHEATING) + wide…
lmeyerov Jun 27, 2026
a1279f6
fix(gfql/polars): engine-aware WITH-scalar MATCH re-entry (no pandas …
lmeyerov Jun 27, 2026
11a28a4
fix(gfql/polars): honest NIE on OPTIONAL MATCH null-fill alignment (n…
lmeyerov Jun 27, 2026
c3f6992
fix(gfql/polars): OPTIONAL MATCH absent-entity renders null, not '()'
lmeyerov Jun 27, 2026
346b81d
fix(gfql/polars): label match on labels List column via list.contains…
lmeyerov Jun 27, 2026
23f26ea
fix(gfql/polars): IEEE NaN comparison semantics + numeric-vs-string NIE
lmeyerov Jun 28, 2026
ecc3794
fix(gfql/polars): honest NIE on ISO temporal comparison (not lexicogr…
lmeyerov Jun 28, 2026
b6da5d5
fix(gfql/polars): adversarial-review fixes — NaN/cross-type/List/temp…
lmeyerov Jun 28, 2026
f83f684
fix(gfql): lower-layer review — polars chain dtype-join crash + chain…
lmeyerov Jun 28, 2026
b4a10c2
fix(gfql/polars): adversarial L2 review — dup-alias, int-div, seed-dt…
lmeyerov Jun 28, 2026
f06e05c
test(gfql/polars): absent-entity null assertion robust to polars vers…
lmeyerov Jun 28, 2026
8577f4f
refactor(gfql/polars): home the polars backend under lazy/engine/pola…
lmeyerov Jun 29, 2026
ad85ac0
feat(gfql/lazy): polars-gpu = GPU execution target of the lazy engine
lmeyerov Jun 27, 2026
23e2b9a
perf(gfql/polars): opt-in CPU streaming collect for large traversals …
lmeyerov Jun 28, 2026
579b86e
test(gfql): cover CPU lazy-collect + polars engine-helper branches; p…
lmeyerov Jun 28, 2026
c5bc2fd
fix(gfql/polars-gpu): raise on GPU-incapable node, never silent CPU f…
lmeyerov Jun 29, 2026
fe8218c
garden(gfql/polars): team-polish — stale paths, user-facing messages,…
lmeyerov Jul 2, 2026
a18c594
fix(gfql/polars): Contains honors regex=/flags=, not just case= (B1)
lmeyerov Jul 2, 2026
15ac696
fix(gfql/polars): clean "library not installed" errors for engine='po…
lmeyerov Jun 29, 2026
2a5c544
feat(gfql/polars-gpu): GFQL_POLARS_GPU_EXECUTOR flag (in-memory defau…
lmeyerov Jun 29, 2026
0784a98
fix(gfql/polars): 3 correctness defects from the NIE audit (no-silent…
lmeyerov Jun 29, 2026
ff2c96e
test(gfql/polars): generative cross-surface conformance matrix (Phase…
lmeyerov Jun 29, 2026
e58814e
test(gfql/polars): conformance matrix v2 — all 4 engines + hot-path c…
lmeyerov Jun 29, 2026
1364e87
feat(gfql/polars): native sqrt / sign scalar fns + count(DISTINCT) ag…
lmeyerov Jun 29, 2026
0fbd0cb
feat(gfql/polars): native CASE WHEN + Match/Fullmatch predicates
lmeyerov Jun 29, 2026
cc8a69e
feat(gfql/polars): native tuple-pattern Startswith/Endswith (OR-fold)
lmeyerov Jun 29, 2026
7d33914
feat(gfql/polars): native get_degrees + with_(extend=True) + DateValu…
lmeyerov Jun 29, 2026
cb79f25
feat(gfql/polars): native list literals + x IN [literals] + IsLeapYea…
lmeyerov Jun 29, 2026
c71af85
refactor(gfql/polars): fix 2 rename stragglers (new engine_polars ref…
lmeyerov Jun 29, 2026
66ce62d
feat(gfql/polars): native get_indegrees/get_outdegrees + size/substri…
lmeyerov Jun 30, 2026
529e443
feat(gfql/polars): native toInteger / toBoolean / toString (provable …
lmeyerov Jun 30, 2026
5d0f31f
test(gfql/polars): Stage 0 — amplify fuzz to exercise multi-hop (skip…
lmeyerov Jun 30, 2026
4361afb
feat(gfql/polars): native fixed-length multi-hop chain (hops=N / max_…
lmeyerov Jun 30, 2026
aee2ce6
test(gfql/polars): cross-surface multi-hop — DAG let() mirror of trav…
lmeyerov Jun 30, 2026
0fd391e
test(gfql/polars): coverage-ledger AXIS 2 — cypher scalar functions
lmeyerov Jun 30, 2026
e8b3ea0
test(gfql/polars): coverage-ledger AXIS 3 — call() safelist
lmeyerov Jun 30, 2026
ddacd25
test(gfql/polars): coverage-ledger AXIS 4 — row-pipeline ops (rollout…
lmeyerov Jun 30, 2026
c65fd6e
feat(gfql/polars): native single-hop undirected edges in multi-edge c…
lmeyerov Jun 30, 2026
42b84cf
feat(gfql/polars): native forward/reverse to_fixed_point (unbounded m…
lmeyerov Jun 30, 2026
0508d55
feat(gfql/polars): native fixed-length undirected multi-hop (last mul…
lmeyerov Jun 30, 2026
9cc21df
feat(gfql/polars): native temporal Between (DateValue bounds); IsIn s…
lmeyerov Jun 30, 2026
3e84a36
test(gfql/polars): conformance for native unwind (literal list) + hon…
lmeyerov Jun 30, 2026
fe4ce06
test(gfql/polars): coverage-ledger closures — 10 row-ops + scalar EQ/…
lmeyerov Jun 30, 2026
509f0e3
test(gfql/polars): scope group_by row-op to polars-vs-pandas (orthogo…
lmeyerov Jun 30, 2026
3d3ef08
test(gfql/polars): lock undirected-single + directed-multihop mixed q…
lmeyerov Jun 30, 2026
62a9391
feat(gfql/polars): native date-part boundary predicates (IsMonthStart…
lmeyerov Jun 30, 2026
73dbab6
fix(gfql/cudf): 3 cross-engine row-pipeline divergences (issue #1663 …
lmeyerov Jun 30, 2026
cb9744e
feat(gfql/polars): native forward/reverse min_hops>1 — 500/500 fuzz p…
lmeyerov Jun 30, 2026
439cf66
fix(gfql): ne()/<> on NULL follows openCypher/SQL 3-valued logic
lmeyerov Jun 30, 2026
d9e7f2f
fix(gfql): membership filter / IN on NULL follows openCypher 3VL (cuDF)
lmeyerov Jun 30, 2026
9935583
refactor(gfql/polars): de-dup eager+lazy hop setup into shared helpers
lmeyerov Jun 30, 2026
960e476
feat(gfql/polars): native toFloat, collect/collect_distinct, WHERE IN…
lmeyerov Jun 30, 2026
3bfe641
fix(gfql): gate DAG NIE pass-through to polars; skip polars conforman…
lmeyerov Jul 1, 2026
3c20c50
feat(gfql): count(*) short-circuit — O(1) count_table op, no full-fra…
lmeyerov Jul 2, 2026
6ea6897
test(gfql): pandas-lane coverage for count(*) short-circuit (count_ta…
lmeyerov Jul 2, 2026
85e1ba6
garden(gfql/polars): followups team-polish — guard doc, dead guards, …
lmeyerov Jul 2, 2026
ba8d360
test(gfql): count_table frame-op unit coverage + per-file floor regen…
lmeyerov Jul 2, 2026
fda318e
Merge remote-tracking branch 'origin/master' into dev/gfql-polars-engine
lmeyerov Jul 4, 2026
66ae4b5
ci(polars): DRY the polars test file list into bin/test-polars.sh
lmeyerov Jul 4, 2026
481c2cc
fix(gfql/polars): convert cuDF to polars via Arrow, not a pandas detour
lmeyerov Jul 4, 2026
7266ba0
fix(gfql/polars): honor validate/warn convention in _pl_from_pandas
lmeyerov Jul 4, 2026
48b4c93
fix(gfql): unify _cudf_from_pandas_best_effort onto validate/warn con…
lmeyerov Jul 4, 2026
eb705c3
refactor(gfql/cypher): drop getattr no-op on declared Plottable attrs
lmeyerov Jul 4, 2026
d3152f6
refactor(gfql/polars): move degree helpers out of chain.py into degre…
lmeyerov Jul 4, 2026
3e3ecb2
refactor(gfql/row): drop getattr no-op on declared Plottable attrs (p…
lmeyerov Jul 4, 2026
d016389
fix(gfql/polars): import degree helpers into chain.py after relocation
lmeyerov Jul 4, 2026
17e7087
refactor(gfql/cypher): remove verified-redundant cast() no-ops
lmeyerov Jul 4, 2026
32ecdb0
refactor(gfql/polars): type dtypes.py params (pl.DataType, not Any)
lmeyerov Jul 4, 2026
9f9c516
feat(gfql/polars): make CPU-streaming/GPU-executor config Python-sett…
lmeyerov Jul 4, 2026
88346bc
refactor(gfql/polars): type row_pipeline expr/dtype helpers (Any -> p…
lmeyerov Jul 4, 2026
d272132
refactor(gfql/polars): type row_pipeline lowering return types (-> Op…
lmeyerov Jul 4, 2026
aca792b
refactor(gfql/polars): type cypher-AST lowering params + lazy config;…
lmeyerov Jul 4, 2026
ee34387
refactor(gfql): drop getattr no-op on declared Plottable attrs (gfql_…
lmeyerov Jul 4, 2026
6c38c15
docs(changelog): accuracy + completeness pass for the polars engine PR
lmeyerov Jul 4, 2026
dadc2b8
fix(gfql/polars): nan_to_null native-polars input (parity with pandas…
lmeyerov Jul 4, 2026
85d082c
refactor(gfql/polars): projection.py getattr->direct + type projectio…
lmeyerov Jul 4, 2026
1b541d1
refactor(gfql): DRY polars-frame detection into Engine.is_polars_df/a…
lmeyerov Jul 4, 2026
a48ce41
fix(gfql/polars): in-query NaN aggregation matches pandas skipna
lmeyerov Jul 4, 2026
5edf027
fix(gfql/polars): NIE-decline Rust-incompatible regex + non-homogeneo…
lmeyerov Jul 4, 2026
37a5c68
docs(gfql/polars): note streaming/executor config is process-global (…
lmeyerov Jul 4, 2026
7ac31ca
fix(types): correct Between._normalize_value return type (own the myp…
lmeyerov Jul 4, 2026
cec3a92
fix(gfql/polars): NIE-decline Boolean modulo to match pandas
lmeyerov Jul 4, 2026
af7f0ad
test(gfql): add polars-lane per-file coverage floors (gfql-polars aud…
lmeyerov Jul 4, 2026
6690a3f
test(gfql): conservative polars coverage floors + surface audit repor…
lmeyerov Jul 4, 2026
022e652
feat(gfql/polars): off-engine call() modality policy (call_mode auto/…
lmeyerov Jul 4, 2026
451743f
docs(changelog): off-engine call() modality policy (call_mode auto/st…
lmeyerov Jul 4, 2026
5ee9f3a
refactor(gfql/polars): type predicates.py + explicit lazy __all__ (P1…
lmeyerov Jul 4, 2026
00fcba6
refactor(gfql/row): type frame_ops ctx via RowPipelineCtx Protocol (P…
lmeyerov Jul 4, 2026
f0a287a
fix(gfql/polars): materialize_nodes edges-only + df_to_engine LazyFra…
lmeyerov Jul 4, 2026
c2c8cf9
docs(gfql): maintenance note on the cuDF toString(float) host round-t…
lmeyerov Jul 4, 2026
aa8c3e5
test(gfql/polars-gpu): GPU-gated parity for both executor modes + 'au…
lmeyerov Jul 4, 2026
4db768a
refactor(gfql/polars): comparison-op whitelists replace 6-way operato…
lmeyerov Jul 4, 2026
8ae3195
refactor(gfql/polars): merge select/with_columns bodies, hoist pl imp…
lmeyerov Jul 4, 2026
2424f45
refactor(gfql/polars): shared endpoint_ids() replaces 8 endpoint-conc…
lmeyerov Jul 4, 2026
4ebb43d
refactor(gfql/polars): degree-dispatch getattr + shared _endpoint_cou…
lmeyerov Jul 4, 2026
1a1061c
refactor(gfql): shared _alias_true_mask + is_polars_df consistency (c…
lmeyerov Jul 4, 2026
1f53e71
refactor(gfql/tests): conformance ledger — 4 hand-copied axis blocks …
lmeyerov Jul 4, 2026
38ad32e
refactor(gfql/tests): matrix clone families -> declarative tables + s…
lmeyerov Jul 4, 2026
33908cd
refactor(gfql/tests): chain/hop graph-shape helpers -> shared utils +…
lmeyerov Jul 4, 2026
f9fa0c2
docs(gfql/polars): review-nit fixes — honest % comment, endpoint_ids …
lmeyerov Jul 4, 2026
f862657
docs(gfql/polars): prose pass — tighter comments/docstrings, zero cod…
lmeyerov Jul 4, 2026
b956d11
refactor(gfql/polars): unify the lazy single-hop into hop_polars — on…
lmeyerov Jul 4, 2026
7acdd41
types(gfql/polars): annotate hop_eager module helpers (review)
lmeyerov Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1020,7 +1020,7 @@ jobs:
# PR hygiene only: combine coverage collected by existing CPU test jobs and
# gate executable package lines touched by this PR. Historical uncovered
# lines outside the diff are intentionally ignored.
needs: [changes, test-core-python, test-gfql-core, generate-lockfiles]
needs: [changes, test-core-python, test-gfql-core, test-polars, generate-lockfiles]
if: ${{ github.event_name == 'pull_request' && (needs.changes.outputs.python == 'true' || needs.changes.outputs.gfql == 'true' || needs.changes.outputs.infra == 'true') && !(needs.changes.outputs.docs_only_latest == 'true') }}
runs-on: ubuntu-latest
timeout-minutes: 5
Expand Down Expand Up @@ -1063,6 +1063,12 @@ jobs:
name: gfql-coverage-audit-py3.12
path: build/changed-line-coverage/artifacts/gfql

- name: Download Polars coverage data
uses: actions/download-artifact@v4
with:
name: polars-coverage-py3.12
path: build/changed-line-coverage/artifacts/polars

- name: Changed-line coverage gate
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
Expand All @@ -1073,7 +1079,8 @@ jobs:
python -m coverage combine \
--data-file=build/changed-line-coverage/combined.coverage \
build/changed-line-coverage/artifacts/core/coverage.core-py3.14 \
build/changed-line-coverage/artifacts/gfql/coverage.gfql-py3.12
build/changed-line-coverage/artifacts/gfql/coverage.gfql-py3.12 \
build/changed-line-coverage/artifacts/polars/coverage.polars-py3.12

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

combine gfql + polars coverage

python bin/changed_line_coverage.py \
--data-file build/changed-line-coverage/combined.coverage \
--base-ref "$BASE_SHA" \
Expand Down Expand Up @@ -1389,10 +1396,39 @@ jobs:
uv pip install -e . --no-deps

- name: Polars tests
if: ${{ matrix.python-version != '3.12' }}
run: |
source pygraphistry/bin/activate
./bin/test-polars.sh

- name: Polars tests with coverage
if: ${{ matrix.python-version == '3.12' }}
run: |
source pygraphistry/bin/activate
mkdir -p build/polars-coverage
COVERAGE_FILE=build/polars-coverage/coverage.polars-py3.12 POLARS_COV=1 ./bin/test-polars.sh

- name: Polars GFQL coverage audit (per-file floors)
if: ${{ matrix.python-version == '3.12' }}
run: |
source pygraphistry/bin/activate
python bin/coverage_audit.py \
--profile gfql-polars \
--skip-tests \
--engine-label ci-polars-py3.12 \
--output-dir build/polars-coverage-audit \
--data-file build/polars-coverage/coverage.polars-py3.12 \
--baseline-file graphistry/tests/compute/gfql/coverage_baselines/ci-polars-py3.12.json
cat build/polars-coverage-audit/gfql-polars-coverage-audit.md | tee -a "$GITHUB_STEP_SUMMARY"

- name: Upload polars coverage
if: ${{ matrix.python-version == '3.12' }}
uses: actions/upload-artifact@v4
with:
name: polars-coverage-py3.12
path: build/polars-coverage/
retention-days: 14

test-core-umap:
needs: [changes, test-minimal-python, test-gfql-core, generate-lockfiles]
if: ${{ success() && (needs.changes.outputs.ai == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') }}
Expand Down
42 changes: 40 additions & 2 deletions CHANGELOG.md

Large diffs are not rendered by default.

182 changes: 182 additions & 0 deletions benchmarks/gfql/cypher_row_pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Benchmark the native polars GFQL row pipeline vs pandas for cypher queries.

Phase 2 of the polars engine enables cypher RETURN / LIMIT / SKIP / DISTINCT /
single-entity WHERE on ``engine='polars'`` (before this increment these raised
NotImplementedError on polars). The heavy traversal + frame ops (filter, dedup,
slice) run natively in polars; only the final row-wise entity-text projection is
host-bridged to pandas. So polars wins most where a row op reduces the set
before projection (LIMIT, selective WHERE, DISTINCT), and is closest to neutral
on a full-table whole-entity RETURN (projection dominates, bridge roundtrip).

Reports median latency and the polars speedup (pandas_ms / polars_ms; > 1 means
polars wins). On a shared host, interleave is implicit (pandas then polars
back-to-back per query); for regression-grade claims run several times and
compare distributions (see plans/gfql-polars-engine memory).

Example::

python benchmarks/gfql/cypher_row_pipeline.py --runs 7 --warmup 2 \
--sizes 10000,100000,1000000 --output /tmp/cypher-row.md
"""

from __future__ import annotations

import argparse
import statistics
import time
from dataclasses import dataclass
from typing import Callable, List, Optional, Tuple

import numpy as np
import pandas as pd

import graphistry

# (name, cypher) — exercised on both engines via g.gfql(cypher, engine=...)
# Native = frame ops (rows/limit/skip/distinct) run in polars; Bridged = the
# cypher expression engine (select/order_by/group_by) runs host-bridged to pandas.
WORKLOADS: List[Tuple[str, str]] = [
# native frame-op path
("RETURN n LIMIT 10", "MATCH (n) RETURN n LIMIT 10"),
("RETURN n SKIP/LIMIT", "MATCH (n) RETURN n SKIP 5 LIMIT 100"),
("WHERE > RETURN LIMIT", "MATCH (n) WHERE n.score > 90 RETURN n LIMIT 50"),
("RETURN DISTINCT n", "MATCH (n) RETURN DISTINCT n"),
("WHERE > RETURN n", "MATCH (n) WHERE n.score > 50 RETURN n"),
("RETURN n (full)", "MATCH (n) RETURN n"),
("rel RETURN m LIMIT", "MATCH (n)-[e]->(m) RETURN m LIMIT 100"),
# host-bridged expression path
("select n.score", "MATCH (n) RETURN n.score"),
("select 2 cols", "MATCH (n) RETURN n.score, n.kind"),
("order_by", "MATCH (n) RETURN n.score ORDER BY n.score DESC"),
("where+select+limit", "MATCH (n) WHERE n.score > 50 RETURN n.score ORDER BY n.score LIMIT 100"),
("group_by count", "MATCH (n) RETURN n.kind, count(n) AS c"),
]


@dataclass
class ResultRow:
workload: str
n_nodes: int
n_edges: int
pandas_ms: Optional[float]
polars_ms: Optional[float]
error: Optional[str] = None

@property
def speedup(self) -> Optional[float]:
if self.pandas_ms and self.polars_ms:
return self.pandas_ms / self.polars_ms
return None


def make_graph(n_nodes: int, n_edges: int, seed: int = 0):
rng = np.random.default_rng(seed)
nodes = pd.DataFrame({
"id": np.arange(n_nodes),
"kind": rng.choice(["x", "y", "z"], size=n_nodes),
"score": rng.integers(0, 100, size=n_nodes),
})
edges = pd.DataFrame({
"s": rng.integers(0, n_nodes, size=n_edges),
"d": rng.integers(0, n_nodes, size=n_edges),
"rel": rng.choice(["r1", "r2", "r3"], size=n_edges),
})
return graphistry.nodes(nodes, "id").edges(edges, "s", "d")


def timeit(fn: Callable[[], object], runs: int, warmup: int) -> float:
for _ in range(warmup):
fn()
samples = []
for _ in range(runs):
t0 = time.perf_counter()
fn()
samples.append((time.perf_counter() - t0) * 1000.0)
return statistics.median(samples)


def _polars_graph(g):
"""Same graph with node/edge frames already in polars, so the polars runs
don't pay a per-call pandas->polars input coercion that the pandas runs avoid
(a real deployment keeps the graph in its engine's native frame type)."""
from graphistry.Engine import Engine, df_to_engine
return g.nodes(df_to_engine(g._nodes, Engine.POLARS), g._node).edges(
df_to_engine(g._edges, Engine.POLARS), g._source, g._destination)


def run(sizes: List[Tuple[int, int]], runs: int, warmup: int) -> List[ResultRow]:
rows: List[ResultRow] = []
for n_nodes, n_edges in sizes:
g_pd = make_graph(n_nodes, n_edges)
g_pl = _polars_graph(g_pd)
for name, query in WORKLOADS:
try:
pandas_ms = timeit(lambda: g_pd.gfql(query, engine="pandas"), runs, warmup)
polars_ms = timeit(lambda: g_pl.gfql(query, engine="polars"), runs, warmup)
rows.append(ResultRow(name, n_nodes, n_edges, pandas_ms, polars_ms))
except Exception as exc: # noqa: BLE001 - bench harness reports, never crashes the sweep
rows.append(ResultRow(name, n_nodes, n_edges, None, None, error=f"{type(exc).__name__}: {exc}"))
return rows


def to_markdown(rows: List[ResultRow]) -> str:
lines = [
"| workload | nodes | edges | pandas_ms | polars_ms | speedup |",
"|----------|-------|-------|-----------|-----------|---------|",
]
for r in rows:
if r.error:
lines.append(f"| {r.workload} | {r.n_nodes} | {r.n_edges} | ERROR | ERROR | {r.error} |")
else:
lines.append(
f"| {r.workload} | {r.n_nodes} | {r.n_edges} | "
f"{r.pandas_ms:.1f} | {r.polars_ms:.1f} | {r.speedup:.2f}x |"
)
return "\n".join(lines)


def _parse_sizes(text: str) -> List[Tuple[int, int]]:
# "nodes:edges,nodes:edges" or "nodes" (edges defaults to 5x nodes)
out: List[Tuple[int, int]] = []
for chunk in text.split(","):
chunk = chunk.strip()
if not chunk:
continue
if ":" in chunk:
nn, ne = chunk.split(":")
out.append((int(nn), int(ne)))
else:
nn = int(chunk)
out.append((nn, nn * 5))
return out


def main() -> None:
try:
import polars # noqa: F401
except ImportError:
raise SystemExit("polars is not installed; install with `pip install polars`")

parser = argparse.ArgumentParser(description="Benchmark GFQL cypher row pipeline pandas vs polars.")
parser.add_argument("--runs", type=int, default=7)
parser.add_argument("--warmup", type=int, default=2)
parser.add_argument(
"--sizes",
default="10000,100000,1000000",
help="Comma list of node counts (edges=5x) or nodes:edges pairs.",
)
parser.add_argument("--output", default="", help="Optional path to write the markdown table.")
args = parser.parse_args()

rows = run(_parse_sizes(args.sizes), args.runs, args.warmup)
table = to_markdown(rows)
print(table)
if args.output:
with open(args.output, "w") as fh:
fh.write(table + "\n")
print(f"\nwrote {args.output}")


if __name__ == "__main__":
main()
70 changes: 70 additions & 0 deletions benchmarks/gfql/multihop_polars_bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Multi-hop polars-vs-pandas chain benchmark (validation for the native multi-hop port).

Confirms native multi-hop polars is on-par-or-better than pandas on non-trivial graphs.
Run on dgx (CPU lane is the relevant comparison; cudf/polars-gpu optional).
"""
import time
import numpy as np
import pandas as pd
import graphistry
from graphistry.compute.ast import n, e_forward, e_undirected


def _graph(nn, ne, seed=0):
rng = np.random.default_rng(seed)
nd = pd.DataFrame({"id": np.arange(nn), "k": rng.integers(0, 5, nn)})
ed = pd.DataFrame({
"s": rng.integers(0, nn, ne),
"d": rng.integers(0, nn, ne),
"w": rng.integers(0, 100, ne),
})
return graphistry.nodes(nd, "id").edges(ed, "s", "d")


def _time(g, ch, engine, reps=3):
g.chain(ch, engine=engine) # warm
best = float("inf")
for _ in range(reps):
t = time.perf_counter()
out = g.chain(ch, engine=engine)
dt = time.perf_counter() - t
best = min(best, dt)
ne = 0 if out._edges is None else len(out._edges)
nn = 0 if out._nodes is None else len(out._nodes)
return best * 1000, nn, ne


CHAINS = {
"fwd-hops2": [n({"id": [0]}), e_forward(hops=2), n()],
"fwd-hops3": [n({"id": [0]}), e_forward(hops=3), n()],
"maxhops4": [n({"id": [0]}), e_forward(max_hops=4), n()],
"sandwiched": [n({"id": [0]}), e_forward(), n(), e_forward(hops=2), n()],
"fwd-tofixed": [n({"id": [0]}), e_forward(to_fixed_point=True), n()],
"und-hops2": [n({"id": [0]}), e_undirected(hops=2), n()],
"und-maxhops3": [n({"id": [0]}), e_undirected(max_hops=3), n()],
}

for (nn, ne) in [(10_000, 50_000), (100_000, 500_000), (500_000, 2_000_000)]:
g = _graph(nn, ne)
print(f"\n=== graph nn={nn:,} ne={ne:,} ===")
for name, ch in CHAINS.items():
engines = ["pandas", "polars"]
try:
import cudf # noqa: F401
engines.append("cudf")
except Exception:
pass
row = []
ref = None
for eng in engines:
try:
ms, rnn, rne = _time(g, ch, eng)
if eng == "pandas":
ref = ms
spd = f"{ref/ms:.2f}x" if (ref and eng != "pandas") else "1.00x"
row.append(f"{eng}={ms:.1f}ms({spd},n={rnn},e={rne})")
except NotImplementedError:
row.append(f"{eng}=NIE")
except Exception as ex:
row.append(f"{eng}=ERR:{type(ex).__name__}")
print(f" {name:12s} " + " ".join(row))
Loading
Loading