Skip to content

Commit 4cbaf3b

Browse files
lmeyerovclaude
andcommitted
perf(gfql): seeded typed-hop fast paths use resident indexes (#1658 x #1755)
With gfql_index_all() resident, the seeded helpers replace their three full-frame scans (O(N) seed row, O(E) frontier isin, O(N) endpoint gather) with node-id searchsorted + CSR adjacency gathers. Decline-gated to the identical scan body: absent/stale index (get_valid fingerprint + identity), non-numeric id families, seed not id-filtered. Local 50k/200k covered shape: pandas 3.11->1.85ms, polars 1.74->1.10ms; win scales with graph size. Pinned: parity+engagement (pandas/polars), string-id decline, stale-index decline. Stacked on perf/gfql-index-preserve-polars-frames (#1767) — polars index residency requires it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
1 parent 1e3d536 commit 4cbaf3b

3 files changed

Lines changed: 265 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
99
<!-- Do Not Erase This Section - Used for tracking unreleased changes -->
1010

1111
### Performance
12+
- **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).
1213
- **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`.
1314
- **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`.
1415
- **GFQL connected-join simple residuals filter natively on polars (#1729/#1755)**: on the polars engines, a connected-join node residual of the #1729 scalar shapes — case-insensitive equality ``(tolower(a.col) = tolower('lit'))`` and scalar comparisons ``(a.col <op> literal)`` for ``= >= <= > <`` — previously dispatched a full sub-``chain()`` per alias (the polars row pipeline has no native ``where_rows``), costing several ms per residual on OLAP group-aggregate queries. `_residual_polars_expr` now translates these shapes to native ``pl.Expr`` filters applied directly to the alias frame (graph-benchmark q5 10.2→6.6ms, q6 10.7→8.9ms on dgx). Null semantics match the ``where_rows`` evaluator (null comparisons drop the row); any unrecognized shape, alias mismatch, or absent column falls back to the existing chain path — and if *any* expr in a residual group fails to translate, the whole group falls back (never a partial mix). Byte-parity verified across an 8-case adversarial differential (nulls, unicode/casefold, numeric ranges, mixed residuals) plus the full suite.

graphistry/compute/chain_fast_paths.py

Lines changed: 177 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,81 @@ def _seeded_scalar_filters(fd: Optional[Dict[str, Any]], df: DataFrameT) -> Opti
4141
return out
4242

4343

44+
def _resident_seed_indexes(
45+
g: Plottable, nodes_df: DataFrameT, edges_df: DataFrameT,
46+
node: str, src: str, dst: str, direction: Direction,
47+
) -> Optional[Tuple[Any, Any, Any, Any]]:
48+
"""(node_id_index, adjacency_index, xp, engine) when BOTH resident indexes
49+
validly cover this directed seeded hop on these EXACT frames (fingerprint +
50+
identity via get_valid), else None — callers keep the scan path, so a stale
51+
or absent index can never change results, only speed."""
52+
from graphistry.Engine import Engine, is_polars_df
53+
from graphistry.compute.gfql.index import get_registry
54+
from graphistry.compute.gfql.index.registry import EDGE_OUT_ADJ, EDGE_IN_ADJ, NODE_ID
55+
from graphistry.compute.gfql.index.engine_arrays import array_namespace
56+
registry = get_registry(g)
57+
if registry.is_empty():
58+
return None
59+
mod = str(type(nodes_df).__module__)
60+
if is_polars_df(nodes_df):
61+
engine = Engine.POLARS
62+
elif 'cudf' in mod:
63+
engine = Engine.CUDF
64+
elif mod.startswith('pandas'):
65+
engine = Engine.PANDAS
66+
else:
67+
return None
68+
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)
71+
if adj is None or nid is None:
72+
return None
73+
xp, _ = array_namespace(engine)
74+
return nid, adj, xp, engine
75+
76+
77+
def _ids_to_key_array(vals: Any, keys: Any, xp: Any) -> Optional[Any]:
78+
"""Values (python list / Series / array) -> deduped backend array in the index
79+
key dtype, nulls dropped (null ids never link — matching the scan path's
80+
dropna semantics). None when the cast is not value-safe (mismatched families
81+
like str-vs-int decline to the scan path rather than risk false matches)."""
82+
try:
83+
arr = xp.asarray(vals.to_numpy() if hasattr(vals, "to_numpy") else vals)
84+
if arr.dtype.kind == "f":
85+
arr = arr[~xp.isnan(arr)]
86+
if arr.dtype.kind not in "iuf" or keys.dtype.kind not in "iuf":
87+
return None # numeric id families only: object/str ids keep the scan path (null-object semantics)
88+
if arr.dtype != keys.dtype:
89+
common = xp.promote_types(arr.dtype, keys.dtype)
90+
arr = arr.astype(common)
91+
return xp.unique(arr)
92+
except (TypeError, ValueError):
93+
return None
94+
95+
96+
def _index_node_rows(nid: Any, ids: Any, xp: Any, engine: Any, nodes_df: DataFrameT) -> Optional[DataFrameT]:
97+
"""Node rows whose id is in ``ids`` via the resident node-id index (positional
98+
gather; row order is id-sorted, covered by the value-identical contract)."""
99+
from graphistry.compute.gfql.index.lookup import lookup_node_rows
100+
from graphistry.compute.gfql.index.engine_arrays import take_rows
101+
arr = _ids_to_key_array(ids, nid.keys_sorted, xp)
102+
if arr is None:
103+
return None
104+
return take_rows(nodes_df, lookup_node_rows(nid, arr, xp), engine)
105+
106+
107+
def _index_edge_rows(adj: Any, ids: Any, xp: Any, engine: Any, edges_df: DataFrameT) -> Optional[DataFrameT]:
108+
"""Edge rows incident to ``ids`` on the indexed side via the CSR adjacency
109+
(searchsorted gather; replaces the O(E) isin scan)."""
110+
from graphistry.compute.gfql.index.lookup import lookup_edge_rows
111+
from graphistry.compute.gfql.index.engine_arrays import take_rows
112+
arr = _ids_to_key_array(ids, adj.keys_sorted, xp)
113+
if arr is None:
114+
return None
115+
rows, _ = lookup_edge_rows(adj, arr, xp)
116+
return take_rows(edges_df, rows, engine)
117+
118+
44119
def _seeded_typed_hop_pandas_cudf(
45120
g: Plottable, n0: ASTNode, n2: ASTNode, e1: ASTEdge,
46121
src: str, dst: str, node: str, direction: Direction,
@@ -71,26 +146,54 @@ def _seeded_typed_hop_pandas_cudf(
71146
# all edges — this is what makes a seeded lookup sub-ms. The id filter goes
72147
# first (int, unique -> ~1 row in one pass) so any remaining object filters
73148
# (label__X->type) run on that tiny survivor frame, not the whole node table.
74-
if n0f:
75-
seed_nodes = nodes_df
76-
for k, v in sorted(n0f.items(), key=lambda kv: 0 if kv[0] == node else 1):
77-
seed_nodes = seed_nodes[seed_nodes[k] == v]
78-
edges = edges_df[edges_df[from_col].isin(seed_nodes[node].dropna())]
79-
else:
80-
edges = edges_df
81-
if ef: # typed edge (edge_match) — now on the reduced frontier
82-
for k, v in ef.items():
83-
edges = edges[edges[k] == v]
84-
85-
# Gather candidate endpoint nodes (both endpoints of surviving edges), then run
86-
# the dest filter, dangling-edge drop and final-node selection on the small
87-
# candidate/edge frames. Selecting from nodes_df keeps only real nodes, so the
88-
# endpoint-in-nodes check subsumes the old NaN-endpoint guard. Membership sets
89-
# are dropna()'d: pandas .isin matches NaN<->NaN, but the general branch's BFS
90-
# joins never join on null keys, so a null id/endpoint must not link.
91-
cand = nodes_df[
92-
nodes_df[node].isin(edges[src].dropna()) | nodes_df[node].isin(edges[dst].dropna())
93-
].drop_duplicates(subset=[node])
149+
# Resident-index acceleration (#1658 x #1755): when node-id + directional
150+
# adjacency indexes are valid for these exact frames, the O(N) seed scan, the
151+
# O(E) frontier isin, and the O(N) candidate gather become positional lookups.
152+
# Any decline (no index, stale fingerprint, unsafe id cast) falls back to the
153+
# scan body below — identical results either way, only speed differs.
154+
ctx = _resident_seed_indexes(g, nodes_df, edges_df, node, src, dst, direction) if (n0f and node in n0f) else None
155+
seed_nodes = edges = cand = None
156+
if ctx is not None:
157+
nid, adj, xp, idx_engine = ctx
158+
seed_nodes = _index_node_rows(nid, [n0f[node]], xp, idx_engine, nodes_df)
159+
if seed_nodes is not None:
160+
for k, v in n0f.items():
161+
if k != node:
162+
seed_nodes = seed_nodes[seed_nodes[k] == v]
163+
edges = _index_edge_rows(adj, seed_nodes[node], xp, idx_engine, edges_df)
164+
if edges is not None:
165+
if ef:
166+
for k, v in ef.items():
167+
edges = edges[edges[k] == v]
168+
if 'cudf' in str(type(edges).__module__):
169+
import cudf as _cd # type: ignore
170+
endpoint_ids = _cd.concat([edges[src], edges[dst]])
171+
else:
172+
import pandas as _pd
173+
endpoint_ids = _pd.concat([edges[src], edges[dst]])
174+
cand = _index_node_rows(nid, endpoint_ids, xp, idx_engine, nodes_df)
175+
if cand is None:
176+
if n0f:
177+
seed_nodes = nodes_df
178+
for k, v in sorted(n0f.items(), key=lambda kv: 0 if kv[0] == node else 1):
179+
seed_nodes = seed_nodes[seed_nodes[k] == v]
180+
edges = edges_df[edges_df[from_col].isin(seed_nodes[node].dropna())]
181+
else:
182+
edges = edges_df
183+
if ef: # typed edge (edge_match) — now on the reduced frontier
184+
for k, v in ef.items():
185+
edges = edges[edges[k] == v]
186+
187+
# Gather candidate endpoint nodes (both endpoints of surviving edges), then run
188+
# the dest filter, dangling-edge drop and final-node selection on the small
189+
# candidate/edge frames. Selecting from nodes_df keeps only real nodes, so the
190+
# endpoint-in-nodes check subsumes the old NaN-endpoint guard. Membership sets
191+
# are dropna()'d: pandas .isin matches NaN<->NaN, but the general branch's BFS
192+
# joins never join on null keys, so a null id/endpoint must not link.
193+
cand = nodes_df[
194+
nodes_df[node].isin(edges[src].dropna()) | nodes_df[node].isin(edges[dst].dropna())
195+
].drop_duplicates(subset=[node])
196+
assert edges is not None and cand is not None # both branches above assign
94197
if n2f: # destination-node filter (to-side)
95198
n2_cand = cand
96199
for k, v in n2f.items():
@@ -130,16 +233,33 @@ def _seeded_typed_return_dst_pandas_cudf(
130233
# frame, never materializing an object column over the whole node table.
131234
# Membership sets are dropna()'d: pandas .isin matches NaN<->NaN, but the full
132235
# pipeline's joins never join on null keys, so a null id/endpoint must not link.
133-
seed_nodes = nodes_df
134-
for k, v in sorted(n0f.items(), key=lambda kv: 0 if kv[0] == node else 1):
135-
seed_nodes = seed_nodes[seed_nodes[k] == v]
136-
edges = edges_df[edges_df[from_col].isin(seed_nodes[node].dropna())]
137-
if ef:
138-
for k, v in ef.items():
139-
edges = edges[edges[k] == v]
140-
# destination nodes = real nodes that are edge to-endpoints, then the dest
141-
# filter, dangling-edge drop and dedup on the small dst/edge frames.
142-
dstn = nodes_df[nodes_df[node].isin(edges[to_col].dropna())]
236+
ctx = _resident_seed_indexes(g, nodes_df, edges_df, node, src, dst, direction) if node in n0f else None
237+
seed_nodes = edges = dstn = None
238+
if ctx is not None:
239+
nid, adj, xp, idx_engine = ctx
240+
seed_nodes = _index_node_rows(nid, [n0f[node]], xp, idx_engine, nodes_df)
241+
if seed_nodes is not None:
242+
for k, v in n0f.items():
243+
if k != node:
244+
seed_nodes = seed_nodes[seed_nodes[k] == v]
245+
edges = _index_edge_rows(adj, seed_nodes[node], xp, idx_engine, edges_df)
246+
if edges is not None:
247+
if ef:
248+
for k, v in ef.items():
249+
edges = edges[edges[k] == v]
250+
dstn = _index_node_rows(nid, edges[to_col], xp, idx_engine, nodes_df)
251+
if dstn is None:
252+
seed_nodes = nodes_df
253+
for k, v in sorted(n0f.items(), key=lambda kv: 0 if kv[0] == node else 1):
254+
seed_nodes = seed_nodes[seed_nodes[k] == v]
255+
edges = edges_df[edges_df[from_col].isin(seed_nodes[node].dropna())]
256+
if ef:
257+
for k, v in ef.items():
258+
edges = edges[edges[k] == v]
259+
# destination nodes = real nodes that are edge to-endpoints, then the dest
260+
# filter, dangling-edge drop and dedup on the small dst/edge frames.
261+
dstn = nodes_df[nodes_df[node].isin(edges[to_col].dropna())]
262+
assert edges is not None and dstn is not None # both branches above assign
143263
if n2f:
144264
for k, v in n2f.items():
145265
dstn = dstn[dstn[k] == v]
@@ -178,17 +298,33 @@ def _seeded_typed_return_dst_polars(
178298
# Membership sets are drop_nulls()'d (null ids/endpoints never link, matching
179299
# the full pipeline's joins) and passed via .implode() (Series-arg is_in is
180300
# deprecated in polars 1.42, see polars#22149).
181-
seed_nodes = nodes_df
182-
for k, v in n0f.items():
183-
seed_nodes = seed_nodes.filter(pl.col(k) == v)
184-
from_ids = seed_nodes.get_column(node).drop_nulls()
185-
if from_ids.len() == 0:
186-
return nodes_df.clear(), edges_df.clear()
187-
edges = edges_df.filter(pl.col(from_col).is_in(from_ids.implode()))
188-
for k, v in ef.items(): # typed edge on the reduced frontier
189-
edges = edges.filter(pl.col(k) == v)
190-
dst_ids = edges.get_column(to_col).drop_nulls().unique()
191-
dstn = nodes_df.filter(pl.col(node).is_in(dst_ids.implode()))
301+
ctx = _resident_seed_indexes(g, nodes_df, edges_df, node, src, dst, direction) if node in n0f else None
302+
seed_nodes = edges = dstn = None
303+
if ctx is not None:
304+
nid, adj, xp, idx_engine = ctx
305+
seed_nodes = _index_node_rows(nid, [n0f[node]], xp, idx_engine, nodes_df)
306+
if seed_nodes is not None:
307+
for k, v in n0f.items():
308+
if k != node:
309+
seed_nodes = seed_nodes.filter(pl.col(k) == v)
310+
edges = _index_edge_rows(adj, seed_nodes.get_column(node), xp, idx_engine, edges_df)
311+
if edges is not None:
312+
for k, v in ef.items():
313+
edges = edges.filter(pl.col(k) == v)
314+
dstn = _index_node_rows(nid, edges.get_column(to_col), xp, idx_engine, nodes_df)
315+
if dstn is None:
316+
seed_nodes = nodes_df
317+
for k, v in n0f.items():
318+
seed_nodes = seed_nodes.filter(pl.col(k) == v)
319+
from_ids = seed_nodes.get_column(node).drop_nulls()
320+
if from_ids.len() == 0:
321+
return nodes_df.clear(), edges_df.clear()
322+
edges = edges_df.filter(pl.col(from_col).is_in(from_ids.implode()))
323+
for k, v in ef.items(): # typed edge on the reduced frontier
324+
edges = edges.filter(pl.col(k) == v)
325+
dst_ids = edges.get_column(to_col).drop_nulls().unique()
326+
dstn = nodes_df.filter(pl.col(node).is_in(dst_ids.implode()))
327+
assert edges is not None and dstn is not None # both branches above assign
192328
for k, v in n2f.items(): # destination-node filter
193329
dstn = dstn.filter(pl.col(k) == v)
194330
# drop dangling edges + dedup destination nodes (mirror the pandas tail)

0 commit comments

Comments
 (0)