Skip to content

Commit 6cb3843

Browse files
lmeyerovclaude
andcommitted
perf(gfql): evaluate indexed edge_match on candidate rows, not the whole edge frame
The index path built the simple-equality `edge_match` mask over ALL E edges and then read it only at `rows[edge_keep[rows]]` -- the handful of positions the CSR adjacency lookup returned. That put an O(E) predicate scan inside an O(degree) traversal, so the indexed path scaled with the graph instead of with the answer. Measured on a 3.18M-node / 14M-edge polars graph (LDBC SNB SF1-shaped), the single `(series == val)` compare was 248ms of a 308ms seeded typed-edge query -- 81% of it, and the reason the indexed surface scaled while the native hop stayed flat. `_build_edge_row_filter` now does the schema-level validation up front (the dtype-mismatch decline that keeps error parity with the scan is O(1) and unchanged), and `_EdgeMatchRowFilter.mask_for(rows)` evaluates `col == val` on the gathered candidate rows. It never examines more elements in total than the eager mask did, so this is strictly less work rather than a tradeoff. A failure mid-traversal abandons the indexed path entirely, so the scan fallback stays parity-safe. Surface query: 309.92 -> 57.44 ms (5.40x), value-identical. Parity: 650 differential comparisons (pandas + polars x 13 shapes x 25 random seeds, dense graph, null-carrying string and numeric columns) -- 0 divergences, comparing columns, dtypes, row counts and full content, including the decline paths. Two new regression tests pin the shape rather than a wall-clock number: the predicate must see only candidate rows, and growing the graph 8x at fixed degree must not grow the number of rows it examines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VARwnAqmvczhz5EmP7TYyZ
1 parent 84be35f commit 6cb3843

2 files changed

Lines changed: 187 additions & 38 deletions

File tree

graphistry/compute/gfql/index/traverse.py

Lines changed: 92 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,73 @@ def is_simple_equality_edge_match(
7474
return True
7575

7676

77-
def _build_edge_keep_mask(
78-
edges: DataFrameT, edge_match: EdgeMatch, engine: Engine, xp: "object"
79-
) -> Optional[ArrayLike]:
80-
"""Boolean array over ORIGINAL edge rows (length E, same indexing as
81-
``AdjacencyIndex.other_values`` / ``row_positions``) selecting rows that satisfy
82-
a simple-equality ``edge_match``.
83-
84-
Built via each frame's native ``col == val`` (so cudf string columns stay on the
85-
cudf layer instead of a cupy string compare). Returns ``None`` on ANY unexpected
86-
shape or error, so the caller falls back to scan rather than risk a divergence.
77+
class _EdgeMatchRowFilter:
78+
"""Evaluates a simple-equality ``edge_match`` on the CSR-matched edge rows only.
79+
80+
The mask is read exactly once per hop, as ``rows[keep[rows]]`` — at the handful of
81+
positions the adjacency lookup returned. Materializing it over all E edges first
82+
therefore put an O(E) predicate scan inside an O(degree) traversal, which is what
83+
made the indexed path scale with the graph instead of with the answer. Evaluating
84+
``col == val`` on the gathered candidate rows makes the predicate proportional to
85+
the edges the traversal actually visits, and never examines more elements in total
86+
than the eager mask did.
87+
88+
Column values are compared with each frame's native ``==`` (so cudf string columns
89+
stay on the cudf layer rather than becoming a cupy string compare), matching the
90+
eager form exactly.
91+
"""
92+
93+
__slots__ = ("_series", "_items", "_engine")
94+
95+
def __init__(self, series: dict, items: list, engine: Engine) -> None:
96+
self._series = series
97+
self._items = items
98+
self._engine = engine
99+
100+
def mask_for(self, rows: ArrayLike) -> Optional[ArrayLike]:
101+
"""Boolean array over ``rows`` (positional, same order), or ``None`` on any
102+
unexpected shape/error so the caller falls back to the scan."""
103+
try:
104+
mask: Optional[ArrayLike] = None
105+
for col, val in self._items:
106+
sub = _gather_series(self._series[col], rows, self._engine)
107+
# Null-safe materialization: on null-carrying columns (pandas nullable
108+
# Int64/boolean/string, polars nulls — which the NaN->null coercion
109+
# makes common) a bare == yields NA cells, and to_numpy() then produces
110+
# an OBJECT-dtype array that later explodes at rows[keep] (IndexError:
111+
# not int/bool). Null == val filters out on the scan path, so fill
112+
# False is parity-exact.
113+
if self._engine in (Engine.POLARS, Engine.POLARS_GPU):
114+
col_mask = cast(ArrayLike, (sub == val).fill_null(False).to_numpy())
115+
elif self._engine == Engine.CUDF:
116+
col_mask = cast(ArrayLike, (sub == val).fillna(False).values)
117+
else:
118+
col_mask = cast(
119+
ArrayLike, (sub == val).fillna(False).to_numpy(dtype=bool))
120+
mask = col_mask if mask is None else cast(ArrayLike, cast(Any, mask) & cast(Any, col_mask))
121+
return mask
122+
except Exception: # pragma: no cover - defensive parity guard
123+
return None
124+
125+
126+
def _gather_series(series: Any, rows: ArrayLike, engine: Engine) -> Any:
127+
"""Positionally gather ``rows`` out of a single column. O(len(rows))."""
128+
if engine in (Engine.POLARS, Engine.POLARS_GPU):
129+
import numpy as np
130+
131+
return series.gather(np.asarray(rows))
132+
# pandas / cudf: positional take accepts numpy (pandas) or cupy (cudf) int arrays
133+
return series.take(rows)
134+
135+
136+
def _build_edge_row_filter(
137+
edges: DataFrameT, edge_match: EdgeMatch, engine: Engine
138+
) -> Optional[_EdgeMatchRowFilter]:
139+
"""Validate a simple-equality ``edge_match`` against the edge schema and return a
140+
per-row evaluator, or ``None`` when the shape isn't covered (caller falls back to
141+
the scan rather than risk a divergence).
142+
143+
All checks here are schema-level (O(1) in E); no predicate is evaluated yet.
87144
"""
88145
try:
89146
if not is_simple_equality_edge_match(edge_match):
@@ -92,41 +149,30 @@ def _build_edge_keep_mask(
92149
_is_numeric_dtype_safe, _is_string_dtype_safe,
93150
)
94151
n_edges = int(edges.shape[0])
95-
mask: Optional[ArrayLike] = None
152+
series: dict = {}
153+
items: list = []
96154
for col, val in edge_match.items():
97155
if col not in edges.columns:
98156
return None
99157
if engine in (Engine.POLARS, Engine.POLARS_GPU):
100-
series = edges.get_column(col)
158+
col_series = edges.get_column(col)
101159
else:
102-
series = edges[col]
160+
col_series = edges[col]
103161
# Obvious dtype mismatch (numeric col vs str val, string col vs numeric
104162
# val): the scan raises GFQLSchemaError E302 where a naive == is silently
105163
# all-False. Decline -> caller falls back to the scan, which raises the
106164
# SAME error (parity-exact; mirrors filter_by_dict's exact two checks,
107165
# skipped like the scan on empty frames).
108166
if n_edges > 0:
109-
dt = series.dtype
167+
dt = col_series.dtype
110168
if _is_numeric_dtype_safe(dt) and isinstance(val, str):
111169
return None
112170
if (_is_string_dtype_safe(dt)
113171
and isinstance(val, (int, float)) and not isinstance(val, bool)):
114172
return None
115-
# Null-safe materialization: on null-carrying columns (pandas nullable
116-
# Int64/boolean/string, polars nulls — which the NaN->null coercion makes
117-
# common) a bare == yields NA cells, and to_numpy() then produces an
118-
# OBJECT-dtype array that later explodes at rows[edge_keep[rows]]
119-
# (IndexError: not int/bool). Null == val filters out on the scan path,
120-
# so fill False is parity-exact.
121-
if engine in (Engine.POLARS, Engine.POLARS_GPU):
122-
col_mask = cast(ArrayLike, (series == val).fill_null(False).to_numpy())
123-
elif engine == Engine.CUDF:
124-
col_mask = cast(ArrayLike, (series == val).fillna(False).values)
125-
else:
126-
col_mask = cast(
127-
ArrayLike, (series == val).fillna(False).to_numpy(dtype=bool))
128-
mask = col_mask if mask is None else cast(ArrayLike, cast(Any, mask) & cast(Any, col_mask))
129-
return mask
173+
series[col] = col_series
174+
items.append((col, val))
175+
return _EdgeMatchRowFilter(series, items, engine)
130176
except Exception: # pragma: no cover - defensive parity guard
131177
return None
132178

@@ -165,14 +211,16 @@ def index_seeded_hop(
165211

166212
xp, _backend = array_namespace(engine)
167213

168-
# Typed-edge (edge_match) support: a boolean mask over ORIGINAL edge rows that
169-
# pass the match predicate, applied to the CSR-matched rows each hop. Gated to
170-
# simple scalar equality + the wavefront path by the coverability check upstream
171-
# (maybe_index_hop); an unsupported shape returns None here => scan (parity-safe).
172-
edge_keep: Optional[ArrayLike] = None
214+
# Typed-edge (edge_match) support: the match predicate is evaluated on the
215+
# CSR-matched rows of each hop, so it costs O(edges visited) rather than O(E).
216+
# Gated to simple scalar equality + the wavefront path by the coverability check
217+
# upstream (maybe_index_hop); an unsupported shape returns None here => scan
218+
# (parity-safe). Schema validation happens now, up front, so an uncovered
219+
# edge_match still declines before any traversal work.
220+
edge_filter: Optional[_EdgeMatchRowFilter] = None
173221
if edge_match:
174-
edge_keep = _build_edge_keep_mask(edges, edge_match, engine, xp)
175-
if edge_keep is None:
222+
edge_filter = _build_edge_row_filter(edges, edge_match, engine)
223+
if edge_filter is None:
176224
return None
177225

178226
# Do NOT narrow the seed to the index key dtype (a node-id int64 seed cast to
@@ -198,11 +246,17 @@ def index_seeded_hop(
198246
neigh_parts: List[ArrayLike] = []
199247
for ix in indices:
200248
rows, matched = lookup_edge_rows(ix, frontier, xp)
201-
if edge_keep is not None:
249+
if edge_filter is not None:
202250
# Keep only CSR-matched rows whose edge passes edge_match. Wavefront-
203251
# only (coverability gate), so the `matched`/first-hop `visited`
204252
# bookkeeping below — which edge_match does NOT filter — is never read.
205-
rows = rows[edge_keep[rows]]
253+
keep = edge_filter.mask_for(rows)
254+
if keep is None:
255+
# Evaluation failed on this candidate batch: abandon the indexed
256+
# path entirely so the caller re-runs the hop on the scan. Nothing
257+
# observable has been mutated, so this stays parity-safe.
258+
return None
259+
rows = rows[keep]
206260
edge_rows_parts.append(rows)
207261
neigh_parts.append(ix.other_values[rows])
208262
matched_parts.append(matched)

graphistry/tests/compute/gfql/index/test_index.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,3 +884,98 @@ def test_hop_dtype_mismatch_edge_match_matches_scan_error(typed_graph, engine):
884884
if engine == "pandas":
885885
from graphistry.compute.exceptions import GFQLSchemaError
886886
assert isinstance(base_err.value, GFQLSchemaError)
887+
888+
889+
# ---- typed-edge predicate cost is proportional to the traversal, not the graph -------
890+
# Regression (measured 2026-07-26, LDBC SNB SF1 lane): the index path built the
891+
# edge_match mask over ALL E edges (`(series == val)`) and then read it at only the
892+
# handful of CSR-matched rows, putting an O(E) predicate scan inside an O(degree)
893+
# traversal. On a 14M-edge graph that single compare was 248ms of a 308ms query, and
894+
# it is why the indexed surface SCALED with the graph while the native hop stayed flat.
895+
# These tests pin the SHAPE (predicate sees only candidate rows), not a wall-clock
896+
# number, so they can't go flaky on a loaded host.
897+
898+
@pytest.mark.parametrize("engine", _cpu_engines())
899+
def test_typed_edge_predicate_only_reads_candidate_rows(typed_graph, engine, monkeypatch):
900+
"""The edge_match predicate must be evaluated on the CSR-matched rows only.
901+
902+
A one-seed hop touches ~deg edges out of 12000; if the predicate is ever handed
903+
the whole column again this assertion fails loudly.
904+
"""
905+
from graphistry.Engine import Engine as _E, df_to_engine
906+
from graphistry.compute.gfql.index import traverse as _traverse
907+
908+
g = typed_graph
909+
if engine == "polars":
910+
g = g.edges(df_to_engine(g._edges, _E.POLARS), "src", "dst").nodes(
911+
df_to_engine(g._nodes, _E.POLARS), "id")
912+
n_edges = int(g._edges.shape[0])
913+
gi = g.gfql_index_all(engine=engine)
914+
915+
seen = []
916+
orig = _traverse._gather_series
917+
918+
def spy(series, rows, eng):
919+
seen.append(int(len(rows)))
920+
return orig(series, rows, eng)
921+
922+
monkeypatch.setattr(_traverse, "_gather_series", spy)
923+
seeds = g._nodes[:1] if engine == "pandas" else g._nodes.head(1)
924+
kwargs = dict(hops=1, return_as_wave_front=True, edge_match={"etype": 1}, engine=engine)
925+
idx = gi.hop(nodes=seeds, **kwargs)
926+
base = g.hop(nodes=seeds, **kwargs)
927+
928+
assert seen, "edge_match predicate never ran on the index path"
929+
assert int(idx._edges.shape[0]) == int(base._edges.shape[0])
930+
# Proportional to the traversal: a single seed's out-degree, not the edge count.
931+
assert max(seen) < n_edges // 10, (
932+
f"predicate saw {max(seen)} rows of {n_edges} — it is scanning the graph, "
933+
"not the traversal candidates")
934+
935+
936+
@pytest.mark.parametrize("engine", _cpu_engines())
937+
def test_typed_edge_predicate_cost_flat_in_graph_size(engine):
938+
"""Growing the graph 8x while holding degree fixed must NOT grow the number of
939+
rows the edge_match predicate examines. This is the property that makes the
940+
indexed path O(degree); it is engine- and schema-independent."""
941+
from graphistry.Engine import Engine as _E, df_to_engine
942+
from graphistry.compute.gfql.index import traverse as _traverse
943+
944+
def probe(n_nodes):
945+
rng = np.random.default_rng(7)
946+
deg = 6
947+
m = n_nodes * deg
948+
edf = pd.DataFrame({
949+
"src": rng.integers(0, n_nodes, m),
950+
"dst": rng.integers(0, n_nodes, m),
951+
"etype": rng.integers(0, 3, m),
952+
})
953+
ndf = pd.DataFrame({"id": np.arange(n_nodes)})
954+
g = graphistry.nodes(ndf, "id").edges(edf, "src", "dst")
955+
if engine == "polars":
956+
g = g.edges(df_to_engine(g._edges, _E.POLARS), "src", "dst").nodes(
957+
df_to_engine(g._nodes, _E.POLARS), "id")
958+
gi = g.gfql_index_all(engine=engine)
959+
seen = []
960+
orig = _traverse._gather_series
961+
962+
def spy(series, rows, eng):
963+
seen.append(int(len(rows)))
964+
return orig(series, rows, eng)
965+
966+
_traverse._gather_series = spy
967+
try:
968+
seeds = g._nodes[:1] if engine == "pandas" else g._nodes.head(1)
969+
gi.hop(nodes=seeds, hops=1, return_as_wave_front=True,
970+
edge_match={"etype": 1}, engine=engine)
971+
finally:
972+
_traverse._gather_series = orig
973+
return sum(seen)
974+
975+
small, big = probe(1000), probe(8000)
976+
assert small > 0 and big > 0
977+
# Degree is held fixed, so candidate rows must not scale with |E| (allow slack
978+
# for the random degree distribution of a single seed).
979+
assert big <= small * 4 + 20, (
980+
f"predicate rows grew {small} -> {big} as the graph grew 8x; "
981+
"the edge_match filter is scaling with the graph")

0 commit comments

Comments
 (0)