Skip to content

Commit f6fa7fb

Browse files
lmeyerovclaude
andcommitted
Merge master into audit/gpu-engine-params-1788-1790
Resolves both conflicts and corrects two defects the conflict exposed. bin/test-polars.sh -- take master's version unchanged. This branch adds graphistry/tests/compute/gfql/test_rows_table_named_middle.py and test_rewrite_param_discard.py to POLARS_TEST_FILES, but #1805 (49db91c, this branch's own merge-base) already added both. Keeping the branch side would list each file twice, so the polars lane would run both modules twice -- and that lane's py3.12 cell is the coverage cell, currently 442s against a 600s cap. Master's version also carries #1814's xdist plumbing, which the branch side predates. CHANGELOG.md -- the entry claimed "neither file was in bin/test-polars.sh, so test_rows_table_named_middle.py ... ran in no CI lane at all". That is false at this branch's base: both files are listed there at lines 43 and 50. Rewritten to the residual that is true -- the suites were parametrized over pandas + polars only, so cuDF and polars-gpu never exercised the table guards; and the module-level pytest.importorskip("polars") skipped the whole file in test-gfql-core, so its pandas cases ran only in the polars lane. The measured claims (45 passed / 3 xfailed / 0 skipped on GB10; 24 mutation failures, 6 per engine) are unchanged. Also types engine_skip_reason, per review: the engine -> required-modules map is hoisted to a module-level Mapping[str, Tuple[str, ...]] constant rather than rebuilt inline per call, the signature is (engine: str, smoke: Callable[[], object]) -> Optional[str], and gpu_environment_reason's "str | None" string annotation is normalized to Optional[str] to match (this file is imported under py3.8 lanes). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
2 parents b07e330 + a101735 commit f6fa7fb

12 files changed

Lines changed: 1943 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 2 deletions
Large diffs are not rendered by default.

benchmarks/gfql/bench_ladybug_cypher.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
#!/usr/bin/env python3
22
"""FAIR GFQL-vs-Ladybug: run Ladybug's benchmark ops as GFQL Cypher MATCH...RETURN
3-
(the row pipeline), NOT dataframe shortcuts. Compares against LadybugDB's PUBLISHED
4-
5M/20M numbers (their figures, their hardware — a cross-machine comparison; treat
5-
ratios as indicative). Ladybug best (5M/20M, ms): full_scan 3789, range 7.5,
6-
point 0.3, count 3.3, out_degree100 59.8, scan_rel_props 15722,
7-
scan_rel_rowid 14562. Kuzu count 46.
3+
(the row pipeline), NOT dataframe shortcuts. Op shapes follow
4+
https://github.com/LadybugDB/kuzu-ladybug-benchmark.
5+
6+
The `LADYBUG` reference column below is a MEASUREMENT, not a literal: LadybugDB 0.18.1,
7+
embedded, on the same host and in the same session as the GFQL arm, on the same generated
8+
5M-node / 20M-edge graph, warm medians (2 warmups + 5 timed runs, median across two
9+
position-balanced slots), timed at Ladybug's FASTEST result-producing scope of the four
10+
measured (zero-copy Arrow / polars / pandas / row iterator) -- Arrow for every scan,
11+
the row iterator for the single-row point lookup -- because GFQL returns a materialized
12+
columnar frame and choosing a slower scope for the competitor is not a comparison. Result values were digest-identical
13+
across the two engines on every op. Runner, raw JSON and full provenance:
14+
pyg-bench `benchmarks/ladybug_scan/` and `results/ladybug-scan-20260728/`.
15+
16+
Numbers here are from dgx-spark (GB10); on another box, re-run the pyg-bench lane rather
17+
than reusing these.
818
919
FAIRNESS: each engine is benchmarked on a graph built in ITS OWN NATIVE frame type
1020
(pandas/polars/cuDF), built ONCE outside the timing loop. An earlier version built the
@@ -49,8 +59,9 @@ def build(engine):
4959
"scan_rel_props": "MATCH (a)-[o]->(b) RETURN a.id, b.id, o.since",
5060
"scan_rel_rowid": "MATCH (a)-[r]->(b) RETURN a.id, b.id",
5161
}
52-
LADYBUG = {"full_scan": 3789, "range": 7.5, "point": 0.3, "count": 3.3,
53-
"scan_rel_props": 15722, "scan_rel_rowid": 14562}
62+
# LadybugDB 0.18.1, dgx-spark, 2026-07-28, Arrow materialization (see module docstring).
63+
LADYBUG = {"full_scan": 364.28, "range": 7.57, "point": 0.30, "count": 3.08,
64+
"scan_rel_props": 1925.61, "scan_rel_rowid": 1449.13}
5465

5566
def _size(res):
5667
df = getattr(res, "_nodes", None)

bin/test-polars.sh

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ set -ex
55
# - Extra args are passed through to the pytest phase
66
# - Set POLARS_COV=1 to collect coverage over graphistry/compute; the coverage
77
# data file location is taken from $COVERAGE_FILE (as the CI py3.12 lane sets it)
8+
# - Set POLARS_XDIST to pick the pytest-xdist worker spec (default `auto`); `0` forces
9+
# the serial path, which is what an A/B of this lane compares against
810
# - Non-zero exit code on fail
911

1012
# Assume [polars,test] installed
@@ -24,11 +26,13 @@ POLARS_TEST_FILES=(
2426
graphistry/tests/compute/gfql/test_engine_polars_chain.py
2527
graphistry/tests/compute/gfql/test_engine_polars_row_pipeline.py
2628
graphistry/tests/compute/gfql/test_engine_polars_binding_rows.py
29+
# engine-parametrized (pandas/polars/cudf/polars-gpu); the pandas params also run in
30+
# test-gfql-core, but only this lane has polars installed
31+
graphistry/tests/compute/gfql/test_varlen_bounded_engine_parity_1787.py
2732
graphistry/tests/compute/gfql/test_engine_polars_with_match_reentry.py
28-
# #1788 / #1790 regression suites: engine-parametrized over pandas/cuDF/polars/polars-gpu,
29-
# so their POLARS params only ever run here (test-gfql-core installs no polars)
30-
graphistry/tests/compute/gfql/test_rows_table_named_middle.py
31-
graphistry/tests/compute/gfql/test_rewrite_param_discard.py
33+
# engine-parametrized: its pandas params run in test-gfql-core, but the polars params
34+
# only ever run here (the file has no module-level importorskip, so nothing else flags it)
35+
graphistry/tests/compute/gfql/test_exec_context_scoping.py
3236
graphistry/tests/compute/gfql/test_engine_polars_cypher_conformance.py
3337
graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py
3438
graphistry/tests/compute/gfql/test_polars_string_predicate_nonstring.py
@@ -39,6 +43,7 @@ POLARS_TEST_FILES=(
3943
graphistry/tests/compute/gfql/test_polars_rows_entity_groupby.py
4044
graphistry/tests/compute/gfql/test_seeded_typed_hop_fastpath.py
4145
graphistry/tests/compute/gfql/test_residual_polars_native.py
46+
graphistry/tests/compute/gfql/cypher/test_grouped_aggregate_fused_polars.py
4247
# module-level `importorskip("polars")` files that previously ran in no lane at all
4348
graphistry/tests/compute/gfql/test_engine_polars_narrow_combine.py
4449
graphistry/tests/compute/gfql/test_engine_polars_semi_key_dedup.py
@@ -61,15 +66,40 @@ POLARS_TEST_FILES=(
6166
graphistry/tests/test_engine_frame_helpers.py
6267
)
6368

69+
# PARALLELISM. The py3.12 cell of this lane is the coverage cell and has repeatedly run out
70+
# of its CI budget; xdist is the lever that does not require a workflow edit (pytest-xdist is
71+
# already in the [test] extra, and test-gfql-core already runs `-n auto` under --cov, so
72+
# coverage+xdist is an established combination in this repo).
73+
# * worker spec `auto` = os.cpu_count(): 4 on a GitHub-hosted ubuntu-latest runner, and it
74+
# scales DOWN on a 2-vCPU runner where a fixed `-n 4` could be slower than serial.
75+
# * --maxprocesses caps the count so a 24-core dev box does not fan out 24 polars processes
76+
# that then oversubscribe polars' own thread pool.
77+
# * --dist load (xdist's default) balances per test. `loadfile` was measured too: it is
78+
# bounded by the single largest module and only reaches 1.4x where `load` reaches 3.2x.
79+
# No test in this lane depends on execution order or on cross-test module state, and the
80+
# pass/skip node-id sets were compared serial-vs-parallel and are identical; POLARS_XDIST_DIST
81+
# is the escape hatch if a future order-dependent test needs `loadfile`/`loadscope`.
82+
XDIST_ARGS=()
83+
if [ "${POLARS_XDIST:-auto}" != "0" ]; then
84+
XDIST_ARGS=(
85+
-n "${POLARS_XDIST:-auto}"
86+
--maxprocesses "${POLARS_XDIST_MAX:-4}"
87+
--dist "${POLARS_XDIST_DIST:-load}"
88+
)
89+
fi
90+
6491
COV_ARGS=()
6592
if [ -n "${POLARS_COV:-}" ]; then
6693
COV_ARGS=(--cov=graphistry --cov-report=)
6794
fi
6895

69-
python -B -m pytest -vv "${COV_ARGS[@]}" "${POLARS_TEST_FILES[@]}" "$@"
96+
python -B -m pytest -vv "${XDIST_ARGS[@]}" "${COV_ARGS[@]}" "${POLARS_TEST_FILES[@]}" "$@"
7097

7198
# cypher-lowering polars-parametrized cases (round ties, lower/upper, =~, numeric fns);
72-
# appended into the same coverage data file when POLARS_COV=1 (CI audit reads it)
99+
# appended into the same coverage data file when POLARS_COV=1 (CI audit reads it).
100+
# Left SERIAL on purpose: it is one module and ~8s of the lane, so worker startup would eat
101+
# the gain. Appending into the data file the xdist phase produced is verified — the merged
102+
# result is line-for-line identical to running the whole script in one go.
73103
COV_APPEND_ARGS=()
74104
if [ -n "${POLARS_COV:-}" ]; then
75105
COV_APPEND_ARGS=(--cov=graphistry --cov-report= --cov-append)

docs/source/gfql/engines.rst

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -171,22 +171,22 @@ guess. The measured pairs behind every claim live in :doc:`performance` (and
171171
Kuzu as the store; GFQL for bulk read analytics.
172172
* - **LadybugDB**
173173
- Actively-maintained **Kuzu fork** (Kuzu is archived); embedded C++, strongly-typed
174-
Cypher, opt-in ART *or* hash indexing, zero-copy Arrow/CSR scans, and **out-of-core
175-
billion-scale** (query a 1.8B-edge graph in <8 GB RAM).
176-
- Against **LadybugDB's published numbers** for their own 5M-node / 20M-edge suite
177-
(their figures, their hardware; GFQL measured separately on the same Cypher row
178-
pipeline, each engine on its **native** frames — a cross-machine comparison, so
179-
read it as indicative): GFQL **wins the scan-shaped ops** — full node scans and
180-
relationship property/rowid scans — by large margins. **Point lookups** are close
181-
in absolute terms but favor Ladybug's B-tree/hash **index seek** over a columnar
182-
scan; a resident GFQL node-id index (tracked in issue #1676) should close that.
183-
Ladybug wins the two ops backed by persistent structure: point lookups and a
174+
Cypher, opt-in ART *or* hash indexing, zero-copy Arrow/CSR scans, and an on-disk
175+
mode its docs describe as processing larger-than-memory workloads.
176+
- **Measured on one host, in one session, on the same 5M-node / 20M-edge graph** —
177+
LadybugDB 0.18.1 embedded against GFQL ``engine='polars'``, result values
178+
digest-identical on every op, Ladybug timed at its fastest (zero-copy Arrow)
179+
materialization: GFQL wins the **node-scan** shapes — a full node scan **6.2×**
180+
and a 1,001-row range scan **1.5×**. **Point lookups** are close in absolute terms
181+
but favor Ladybug's hash/ART **index seek** over a columnar scan; a resident GFQL
182+
node-id index (tracked in issue #1676) should close that. Ladybug also holds a
184183
cached relationship ``COUNT(*)`` (an O(1) count vs GFQL's O(E) endpoint-validated
185184
scan — a dataframe has no referential integrity). GFQL's angle is
186185
dataframe-native, in-process, and GPU-accelerated with no separate store to
187-
load/index. Details and reproducer in the :doc:`performance` methodology notes.
188-
- **Complement:** Ladybug is a durable embedded store with an out-of-core mode
189-
(billion-scale in <8 GB RAM); GFQL is a query engine over your dataframes. GFQL's
186+
load/index. Numbers, provenance and reproducer in the :doc:`performance`
187+
methodology notes.
188+
- **Complement:** Ladybug is a durable embedded store with an on-disk,
189+
larger-than-memory mode; GFQL is a query engine over your dataframes. GFQL's
190190
*default* is in-memory, but it is **not limited to it** — Polars streaming
191191
(``GFQL_POLARS_CPU_STREAMING=1``, disk-spill) and the cudf-polars streaming executor
192192
(``GFQL_POLARS_GPU_EXECUTOR=streaming``) are larger-than-memory paths

docs/source/gfql/performance.rst

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,10 @@ gathers (PR #1770). Neo4j still wins recent-replies — reported as-is.
136136
OLAP multi-join
137137
~~~~~~~~~~~~~~~
138138

139-
On the graph-benchmark OLAP multi-join queries at 100k-node scale with
140-
``engine='polars'``: **q8 runs in 5.0 ms vs 1,004 ms for embedded Kuzu (200×)**; q9 is
141-
**14.2×**.
139+
On the graph-benchmark OLAP multi-join queries at 100k-node scale (100,000 persons /
140+
2.4M ``FOLLOWS``) with ``engine='polars'``, against embedded Kuzu 0.11.3 on the same host
141+
and in the same session: GFQL wins q8, and **q9 runs in 66.6 ms vs 84.1 ms (1.26×)**. Both
142+
sides run the same canonical query text and return identical values.
142143

143144
When not to use GFQL
144145
~~~~~~~~~~~~~~~~~~~~
@@ -226,14 +227,20 @@ Methodology (prior sweep)
226227
``benchmarks/gfql/pandas_vs_polars.py``, and ``benchmarks/gfql/index_vs_kuzu_prepared.py``
227228
(vs kuzu). Numbers on this page are rendered from saved runs; the page does not re-run
228229
them.
229-
- **LadybugDB comparison** (referenced qualitatively in :doc:`engines`): the Ladybug
230-
figures are **their published results on their hardware**; the GFQL side ran on the host
231-
above via ``benchmarks/gfql/bench_ladybug_cypher.py`` (5M/20M synthetic per their suite
232-
shape, native frames per engine, warm medians) — a cross-machine comparison, disclosed
233-
as such. GFQL won the scan-shaped ops by large margins (full node scan ~65×,
234-
relationship property/rowid scans ~3.5–3.7×); Ladybug won the two ops backed by
235-
persistent structure — point lookups (index seek vs columnar scan) and a cached
236-
relationship ``COUNT(*)``.
230+
- **LadybugDB comparison** (referenced qualitatively in :doc:`engines`): **both sides
231+
measured on the host above, in one session, on the same generated 5M-node / 20M-edge
232+
graph** — LadybugDB **0.18.1** embedded in a host venv against GFQL ``engine='polars'``
233+
in the container above. Op shapes are those of
234+
`LadybugDB/kuzu-ladybug-benchmark <https://github.com/LadybugDB/kuzu-ladybug-benchmark>`_
235+
via ``benchmarks/gfql/bench_ladybug_cypher.py``; warm medians (2 warmups + 5 timed runs),
236+
slots interleaved L G G L, and **every op's result values are digest-identical across the
237+
two engines**. Ladybug is timed at its **fastest** result-producing scope of the four measured — for
238+
both cells below that is zero-copy Arrow, not its Python row iterator — because GFQL
239+
returns a materialized columnar frame.
240+
GFQL wins the node-scan shapes: **full node scan 59.0 ms vs 364.3 ms (6.2×)** and the
241+
1,001-row **range scan 5.1 ms vs 7.6 ms (1.5×)**. Point lookups stay with Ladybug's
242+
index seek over a columnar scan (a resident GFQL node-id index is tracked in issue
243+
#1676), as does a cached relationship ``COUNT(*)``.
237244

238245
There is **no universal winner**: ``polars`` typically takes over from ~10K edges up
239246
(``pandas`` still wins trivial sub-millisecond operations), and the right GPU

graphistry/compute/gfql/exec_context.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,29 @@ def clear_row_exec_context(g: "Plottable") -> "Plottable":
4848
``WITH`` query carries the seed, and a follow-up query on that result -- a different
4949
graph entirely -- is answered against it (the same #1786 defect, one hop removed).
5050
Returns ``g`` unchanged when there is nothing to strip, so the common path is free.
51+
52+
WHY ``None`` AND NOT "RESTORE WHAT ``g`` CARRIED ON ENTRY". ``attach`` above INHERITS on
53+
the way in (a ``None`` argument keeps what ``g`` already carries), so the asymmetry is
54+
real and the save/restore question is a fair one. Three reasons it is settled the other
55+
way, each pinned by ``tests/compute/gfql/test_exec_context_scoping.py``:
56+
57+
* This function is PURE -- it returns ``g.bind()`` and never writes through to the object
58+
it was handed, so an outer scope that set the field still has it afterwards. There is no
59+
caller state to save. That is what separates this from #1786, which WAS an in-place
60+
write onto the caller's own graph; restoring is the fix for a mutation, and there is no
61+
mutation here.
62+
* The only channel restore would change is the RETURN VALUE, and putting the seed back
63+
there IS the second half of #1786. Measured: hand-restoring the seed onto a result
64+
changes the answer of the next query run on that result (7 -> 2 -> 1 rows).
65+
* No execution frame ever inherits a context it did not set. The cross-segment ``WITH``
66+
seed travels as the explicit ``start_nodes`` PARAMETER (``chain_impl(..., start_nodes=)``,
67+
``_compiled_query_reentry_state``), never through this field, so the field's lifetime is
68+
exactly ONE boundary-call run. Instrumenting ``attach`` over ``tests/compute`` recorded
69+
3907 calls and ZERO inheriting ones. (53 of them DID enter on a graph already carrying a
70+
seed -- nested boundary frames -- but each was handed the IDENTICAL ``start_nodes``
71+
parameter, so the value is still owned by the frame that sets it.) The test above re-runs
72+
that as an assertion, so a future path that starts relying on inheritance reopens this
73+
decision loudly.
5174
"""
5275
if g._gfql_start_nodes is None and g._gfql_rows_base_graph is None:
5376
return g

graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,6 +1312,41 @@ def _names(lf: pl.LazyFrame) -> List[str]:
13121312
)
13131313
if _resolved_min != 1:
13141314
return None
1315+
# #1787, same root-cause family as the unbounded shapes #1781 declined: pandas'
1316+
# step_pairs come from the var-length `edge_op.execute` hop, whose hop-window
1317+
# pruning -- and, when seeded, its per-seed BFS -- changes an edge multiplicity
1318+
# this raw-edge rebuild cannot reproduce. Declining is a DELIBERATE divergence
1319+
# from master, which served these: parity-or-NIE means a loud error, never a
1320+
# different number. Shrink the gate again once the multiplicity is reconstructible.
1321+
# WHICH shapes, why each boundary sits where it does, and the counts that prove
1322+
# each one are executable rather than prose -- every claim that used to be written
1323+
# out here is now a named test in:
1324+
# graphistry/tests/compute/gfql/test_varlen_bounded_engine_parity_1787.py
1325+
#
1326+
# Keyed on an EXPLICIT window, NOT on `sem.is_multihop`: `-[*1..1]-` resolves to
1327+
# min == max == 1, is therefore not multihop, and pandas still routes it here.
1328+
if op.min_hops is not None or op.max_hops is not None:
1329+
_vl_max = op.max_hops if op.max_hops is not None else op.hops
1330+
_vl_min = op.min_hops if op.min_hops is not None else (
1331+
op.hops if op.hops is not None else 1
1332+
)
1333+
# a seed is anything that starts the segment from less than the whole node
1334+
# set: a filtered start alias, or a re-entry / `WITH` seed frame
1335+
_prev_op = ops[idx - 1] if idx >= 1 else None
1336+
_seeded_start = start_nodes is not None or (
1337+
isinstance(_prev_op, ASTNode) and bool(_prev_op.filter_dict)
1338+
)
1339+
if _vl_max is not None:
1340+
if op.direction == "undirected":
1341+
if _vl_max == 1: # the degenerate window `-[*1..1]-` / `-[*1]-`
1342+
return None
1343+
if _seeded_start or idx > 1: # doubled-pair expansion over-counts
1344+
return None
1345+
# `max_reached_hop` (compute/hop.py) is a dedup-by-node BFS eccentricity,
1346+
# not a longest-walk length, so pandas prunes where this rebuild expands
1347+
# a different edge multiset
1348+
elif _vl_min >= 3 or (_vl_min >= 2 and _seeded_start):
1349+
return None
13151350
if op.direction not in ("forward", "reverse", "undirected"):
13161351
return None
13171352
if any(

0 commit comments

Comments
 (0)