Skip to content

Commit a929331

Browse files
lmeyerovclaude
andcommitted
fix(gfql): resident-index gate — no float-collapsing id promotes, cudf device path, polars-gpu tags, serve-asserted tests
Review findings (plans/review-pr-1768/review.md): B1 int64<->uint64 promotes to float64 and collapses ids >= 2^53 into false matches (repro'd: seed 2^62+1 matched node 2^62) — integral pairs whose promote is float now DECLINE to the exact-compare scan path. M2 spies count SERVES (non-None), not calls, so a gate regression that declines everything fails the suite. M1 native-chain hop indexed branch + reverse/EDGE_IN_ADJ now tested (serve-asserted, parity). M3 cudf ids via .dropna().values (device array; to_numpy raised on nulls and round-tripped host). M4 polars frames also accept polars-gpu-tagged indexes (same numpy sidecars + polars gather). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
1 parent fddb9c1 commit a929331

3 files changed

Lines changed: 88 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1010

1111
### Performance
1212
- **GFQL seeded fast path covers single-alias property RETURNs (LDBC IS5 shape) (#1755)**: `MATCH (m {id})-[:T]->(p) RETURN p.a AS x, p.b` — which lowers to `rows(source=p)+select(items)` rather than a whole-row projection — now takes the seeded typed-hop fast path: the deduped destination rows are renamed/selected directly (value-identical to the rows-pivot + select pipeline; row order may differ). Dtype parity with the full pandas path is exact: non-id properties ride the rows-pivot there, which upcasts int/float to float64 and bool to object, so the lean tail applies the same casts (the id property keeps its dtype); dtype classes not verified against the pivot (datetimes, pandas extension dtypes, categoricals) decline. `res._edges` is the same empty edges frame the full path yields (never None). Conservative declines keep full-path semantics for everything else: cross-alias refs (`RETURN m.x, p.y`), mixed whole-row+property, DISTINCT/ORDER BY/LIMIT (extra lowered ops), expr items, properties absent from the node frame (the full path's null/error semantics must apply), and requested-vs-actual engine mismatches (the full path converts to the requested engine). Differential fast-vs-full sweep across all of the above on pandas + polars: exact parity incl. dtypes and edges, engagement pinned both ways. Measured on LDBC SNB SF1 (dgx, harness row-validated): `message-creator` (IS5) 116.3→38.3 ms on polars (the residue is the seed scan, targeted separately by resident-index coverage).
13-
- **GFQL seeded typed-hop fast paths use resident indexes (#1658 x #1755)**: with `gfql_index_all()` resident, the seeded fast-path helpers (`_seeded_typed_hop_pandas_cudf`, `_seeded_typed_return_dst_pandas_cudf`, `_seeded_typed_return_dst_polars`) replace their three full-frame scans — O(N) seed-row lookup, O(E) frontier `isin`, O(N) endpoint gather — with positional index lookups (node-id searchsorted + CSR adjacency gather), so a seeded Cypher lookup stops paying graph-size costs entirely. Decline-gated to fall back to the identical scan body: absent/stale index (fingerprint+identity via `get_valid`), non-numeric id families (object/str ids keep the scan path's null semantics), seed not id-filtered. Results identical either way (row order may differ; node-id index implies unique ids). Local 50k/200k covered-shape: pandas 3.11→1.85ms, polars 1.74→1.10ms; the win scales with graph size (the residue is parse/lowering, not scans). Pinned in `TestResidentIndexSeededFastPath` (parity+engagement pandas/polars, string-id decline, stale-index decline).
13+
- **GFQL seeded typed-hop fast paths use resident indexes (#1658 x #1755)**: with `gfql_index_all()` resident, the seeded fast-path helpers (`_seeded_typed_hop_pandas_cudf`, `_seeded_typed_return_dst_pandas_cudf`, `_seeded_typed_return_dst_polars`) replace their three full-frame scans — O(N) seed-row lookup, O(E) frontier `isin`, O(N) endpoint gather — with positional index lookups (node-id searchsorted + CSR adjacency gather), so a seeded Cypher lookup stops paying graph-size costs entirely. Decline-gated to fall back to the identical scan body: absent/stale index (fingerprint+identity via `get_valid`), non-numeric id families (object/str ids keep the scan path's null semantics), seed not id-filtered. Results identical either way (row order may differ; node-id index implies unique ids). dgx 50k/200k covered-shape: pandas 1.71→0.86ms, polars 2.02→0.69ms (vs Kuzu's same-box 1.06ms); SF1-scale 3.2M/17M polars 47.2→2.25ms — polars numbers measured with a polars-engine index resident, which today requires `gfql_index_all(engine='polars')` on polars frames (until #1767 lands, AUTO index builds swap polars frames to pandas; pandas/cuDF flows are unaffected and independent). Pinned in `TestResidentIndexSeededFastPath` (parity+engagement pandas/polars, string-id decline, stale-index decline, uint64/int64 promote decline, reverse-direction serving).
1414
- **GFQL seeded typed-hop fast path (#1755)**: a seeded typed 1-hop — native chain `[n({id}), e_forward(), n({type})]` or Cypher `MATCH (m {id})-[:T]->(p) RETURN p` — previously paid the full two-pass chain machinery (~20-40ms: whole-frame combines, full-column type filters, rows-pivot projection). A new seed-first fast path recognizes exactly this shape and reduces the graph to the seed's 1-hop neighborhood before any of that work: native chain 32.6→0.93ms (35×), Cypher RETURN 39.2→1.9ms (20×) on pandas, with cuDF covered via the shared DataFrame API (30.8→4.7ms). Value-identical to the full path by construction (same rows/columns/dtypes; row order and index may differ) — the helpers return `None` and fall through for anything outside the exact shape or carrying full-path side-channels (multi-hop, variable-length, undirected, predicate filters, reverse patterns, missing bindings, list-`labels` columns, policy hooks, same-path WHERE, OPTIONAL MATCH null rows, and WITH..MATCH carried seeds all decline). Null ids/endpoints never link (membership sets are null-dropped, matching the full pipeline's joins). Verified with an independent oracle (results checked against the creator set hand-computed from the raw frames, not merely fast-vs-slow agreement) plus a differential fast-vs-full sweep across shapes × engines in `test_seeded_typed_hop_fastpath.py`.
1515
- **GFQL seeded typed-hop fast path on polars/polars-gpu (#1755)**: extends the seeded typed-hop Cypher fast path to the polars engines via native polars filters (`_seeded_typed_return_dst_polars`), so a seeded typed 1-hop RETURN also lands in single-digit ms on polars (13.7→3.4ms, 4×) and polars-gpu (24.6→2.5ms, 10×). Dispatch is on the ACTUAL frame type (`is_polars_df`), not the requested engine, because WITH..MATCH reentry can request polars while handing pandas-materialized frames. Same decline contract as the pandas path (undirected/multi-hop/varlen/predicates fall through; value-identical for the covered shape), verified by the per-engine differential sweep and oracle tests in `test_seeded_typed_hop_fastpath.py`.
1616
- **GFQL two-star grouped-count OLAP queries collect once on polars (#1755)**: profiling showed the residual two-star path (graph-benchmark q5-q7 shapes) spent ~72% of its runtime on the fixed per-op cost of ~27 eager polars operations. When every residual translates natively (see the entry below), the whole plan — base filter_dicts, residual filters, typed-edge filters, semi-joins, group-prop lookup — now composes as ONE `pl.LazyFrame` plan collected once at the join (`filter_by_dict_polars` gains a `filter_expr_by_dict_polars` twin: same column/dtype resolution and typed-error/NIE contract, expression only). Value-identical to the eager lane it replaces (same filters/joins/aggregation), INCLUDING the empty-match boundary: the eager all-left-counts==1 shortcut's single `n=0` row (the openCypher count over no rows) is reproduced — the left-counts frame rides the same single collect. Edges are engine-converted before going lazy, so pandas frames under `engine='polars'` (the WITH..MATCH reentry shape) take the lane instead of crashing; LazyFrame inputs and untranslatable residuals decline to the eager path. Measured on dgx vs warm Kuzu, same session: q5 7.7→3.8ms (flips to a GFQL win vs 5.2), q6 9.8→6.1 (flips vs 7.3), q7 8.7→5.7 (flips vs 6.9) — moving the graph-benchmark scoreboard from 4W/2T/3L to 7W/1T/1L. Differential tests spy the extracted `_connected_join_two_star_fused_polars` helper and ASSERT lane engagement (a `count(*)` query declines the whole two-star path — pinned as a decline shape): fused vs forced-eager exact-row parity (ORDER BY pinned), the ungrouped empty-match n=0 row, pandas-frames-under-polars-engine parity, empty-result shape, pandas oracle.

graphistry/compute/chain_fast_paths.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,17 @@ def _resident_seed_indexes(
6666
else:
6767
return None
6868
kind = EDGE_OUT_ADJ if direction == "forward" else EDGE_IN_ADJ
69-
adj = registry.get_valid(kind, edges_df, (src, dst), engine)
70-
nid = registry.get_valid(NODE_ID, nodes_df, (node,), engine)
69+
engines = [engine]
70+
if engine == Engine.POLARS:
71+
# an index built with explicit engine='polars-gpu' serves the same eager
72+
# polars frames (same numpy sidecars + polars row-gather)
73+
engines.append(Engine.POLARS_GPU)
74+
adj = nid = None
75+
for eng_try in engines:
76+
adj = registry.get_valid(kind, edges_df, (src, dst), eng_try)
77+
nid = registry.get_valid(NODE_ID, nodes_df, (node,), eng_try)
78+
if adj is not None and nid is not None:
79+
break
7180
if adj is None or nid is None:
7281
return None
7382
xp, _ = array_namespace(engine)
@@ -80,13 +89,24 @@ def _ids_to_key_array(vals: Any, keys: Any, xp: Any) -> Optional[Any]:
8089
dropna semantics). None when the cast is not value-safe (mismatched families
8190
like str-vs-int decline to the scan path rather than risk false matches)."""
8291
try:
83-
arr = xp.asarray(vals.to_numpy() if hasattr(vals, "to_numpy") else vals)
92+
if 'cudf' in str(type(vals).__module__):
93+
vals = vals.dropna()
94+
raw = vals.values # device array; to_numpy() raises on nulls + round-trips host
95+
elif hasattr(vals, "to_numpy"):
96+
raw = vals.to_numpy()
97+
else:
98+
raw = vals
99+
arr = xp.asarray(raw)
84100
if arr.dtype.kind == "f":
85101
arr = arr[~xp.isnan(arr)]
86102
if arr.dtype.kind not in "iuf" or keys.dtype.kind not in "iuf":
87103
return None # numeric id families only: object/str ids keep the scan path (null-object semantics)
88104
if arr.dtype != keys.dtype:
89105
common = xp.promote_types(arr.dtype, keys.dtype)
106+
if arr.dtype.kind in "iu" and keys.dtype.kind in "iu" and common.kind == "f":
107+
# int64<->uint64 promotes to float64, which collapses distinct ids
108+
# >= 2^53 into false matches; the scan path compares exactly -> decline.
109+
return None
90110
arr = arr.astype(common)
91111
return xp.unique(arr)
92112
except (TypeError, ValueError):

graphistry/tests/compute/gfql/test_seeded_typed_hop_fastpath.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -818,11 +818,17 @@ def _canon(self, res):
818818
return df[cols].sort_values(cols).reset_index(drop=True)
819819

820820
def _spied(self, g, q, engine, monkeypatch):
821+
# counts SERVES (non-None returns), not calls: a gate regression that
822+
# declines everything must fail these tests, not silently pass them
821823
import graphistry.compute.chain_fast_paths as cfp
822824
counts = {"n": 0}
823825
on = cfp._index_node_rows
824-
monkeypatch.setattr(cfp, "_index_node_rows",
825-
lambda *a, **k: (counts.__setitem__("n", counts["n"] + 1), on(*a, **k))[1])
826+
827+
def spy(*a, **k):
828+
out = on(*a, **k)
829+
counts["n"] += out is not None
830+
return out
831+
monkeypatch.setattr(cfp, "_index_node_rows", spy)
826832
res = g.gfql(q, engine=engine)
827833
monkeypatch.setattr(cfp, "_index_node_rows", on)
828834
return res, counts["n"]
@@ -840,7 +846,11 @@ def test_indexed_parity_and_engagement(self, engine, monkeypatch):
840846
else:
841847
mk = lambda: graphistry.nodes(ndf, "id").edges(edf, "src", "dst") # noqa: E731
842848
plain = mk().gfql(self.Q, engine=engine)
843-
indexed, hits = self._spied(mk().gfql_index_all(), self.Q, engine, monkeypatch)
849+
# explicit engine: on master, gfql_index_all(AUTO) on polars frames still
850+
# swaps them to pandas (resolve_engine legacy input-format policy), so the
851+
# polars index must be requested explicitly; #1767 (separate PR) makes
852+
# AUTO preserve polars frames, at which point bare gfql_index_all() also works.
853+
indexed, hits = self._spied(mk().gfql_index_all(engine=engine), self.Q, engine, monkeypatch)
844854
assert hits > 0, "resident index did not serve the seeded fast path"
845855
pd.testing.assert_frame_equal(self._canon(indexed), self._canon(plain))
846856

@@ -872,3 +882,54 @@ def test_stale_index_declines_to_scan(self, monkeypatch):
872882
plain = graphistry.nodes(ndf, "id").edges(edf2, "src", "dst").gfql(self.Q, engine="pandas")
873883
assert counts["n"] == 0, "stale index must not serve"
874884
pd.testing.assert_frame_equal(self._canon(got), self._canon(plain))
885+
886+
887+
def test_native_chain_hop_indexed_parity_forward_and_reverse(self, monkeypatch):
888+
"""M1 pin: the native-chain hop helper's indexed branch (only reachable via
889+
chain ops, never Cypher) — forward (EDGE_OUT_ADJ) and reverse (EDGE_IN_ADJ),
890+
serve-asserted, parity vs the un-indexed graph."""
891+
from graphistry.compute.ast import n, e_forward, e_reverse
892+
import graphistry.compute.chain_fast_paths as cfp
893+
ndf, edf = self._frames()
894+
mk = lambda: graphistry.nodes(ndf, "id").edges(edf, "src", "dst") # noqa: E731
895+
for ops in (
896+
[n({"id": 33}), e_forward(edge_match={"type": "KNOWS"}), n({"type": "Person"})],
897+
[n({"id": 33}), e_reverse(edge_match={"type": "KNOWS"}), n({"type": "Person"})],
898+
):
899+
serves = {"n": 0}
900+
oe = cfp._index_edge_rows
901+
902+
def spy(*a, **k):
903+
out = oe(*a, **k)
904+
serves["n"] += out is not None
905+
return out
906+
monkeypatch.setattr(cfp, "_index_edge_rows", spy)
907+
got = mk().gfql_index_all().gfql(ops, engine="pandas")
908+
monkeypatch.setattr(cfp, "_index_edge_rows", oe)
909+
assert serves["n"] > 0, f"indexed hop branch did not serve for {ops[1].direction}"
910+
plain = mk().gfql(ops, engine="pandas")
911+
pd.testing.assert_frame_equal(self._canon(got), self._canon(plain))
912+
913+
def test_uint64_int64_id_mix_declines_not_collapses(self, monkeypatch):
914+
"""B1 pin: int64<->uint64 promotes to float64, which collapses ids >= 2**53
915+
into false matches; the gate must DECLINE (scan path compares exactly)."""
916+
import graphistry.compute.chain_fast_paths as cfp
917+
big = np.uint64(2**62)
918+
ndf = pd.DataFrame({"id": np.array([big, big + np.uint64(1), np.uint64(7)], dtype=np.uint64),
919+
"type": ["Person"] * 3})
920+
edf = pd.DataFrame({"src": np.array([int(big) + 1], dtype=np.int64),
921+
"dst": np.array([7], dtype=np.int64), "type": ["KNOWS"]})
922+
g = graphistry.nodes(ndf, "id").edges(edf, "src", "dst").gfql_index_all()
923+
q = f"MATCH (m {{id: {int(big) + 1}}})-[:KNOWS]->(p) RETURN p"
924+
serves = {"n": 0}
925+
on = cfp._index_node_rows
926+
927+
def spy(*a, **k):
928+
out = on(*a, **k)
929+
serves["n"] += out is not None
930+
return out
931+
monkeypatch.setattr(cfp, "_index_node_rows", spy)
932+
got = g.gfql(q, engine="pandas")
933+
monkeypatch.setattr(cfp, "_index_node_rows", on)
934+
plain = graphistry.nodes(ndf, "id").edges(edf, "src", "dst").gfql(q, engine="pandas")
935+
pd.testing.assert_frame_equal(self._canon(got), self._canon(plain))

0 commit comments

Comments
 (0)