Skip to content

Commit 39d1134

Browse files
lmeyerovclaude
andcommitted
perf(gfql): undirected hop builds both orientations without deduplicating the doubled edge frame
Every consumer of the doubled frame dedups on the edge id, so the whole-frame drop_duplicates over 2E rows (once per hop call, three times per chain) is redundant; the frame is now the plain concat the polars hop uses. Pins: set-oracle parity with self-loops, parallel edges and hub seeds on pandas, polars and cuDF; self-loops kept once through the multi-hop path; categorical endpoints with differing category sets on pandas. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WwMmVFo44ADiRRj5cxh1i1
1 parent 9226829 commit 39d1134

4 files changed

Lines changed: 125 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1616

1717
* GFQL: two basic Cypher shapes fell off the seeded fast paths on every engine. A seeded typed hop whose RETURN projects properties of more than the destination alias (`MATCH (m {id})-[r:T]->(p) RETURN m.id, r.since, p.name`) went through the row pipeline's connected-bindings frame builder; the seeded fast path now projects any number of properties from any alias of the hop (seed node, edge, destination node) from the rows it already holds, one row per matched edge, and still declines anything beyond one hop or an absent property to the full path. A seeded single-node pattern (`MATCH (p {id}) RETURN p` / `RETURN p.a, p.b`) ran the full two-pass chain over the node table with no index consulted; a new `seeded_node_lookup` fast path resolves the seed through the resident node-id index, a resident node-property index, or one scalar scan and projects the matched rows directly. Seed resolution is shared with the seeded typed hop, so a seed predicate on an indexed property that is not the node binding now uses the property index there too. On the SNB SF0.1 fixture the IS1 text went from 19 ms to 4 ms and the node-only lookup from 14 ms to 1 ms on pandas; both shapes report `fast_path served` in `gfql_explain` on pandas, polars and cuDF.
1818
* GFQL: every Cypher string query re-read the node table's dtypes to build its compile-cache key, and that read scans the values of every `object` column (the string-content gate for predicate pushdown). On a wide pandas node table this cost more than the query it keyed: an SNB SF0.1 seeded lookup on 327k nodes × 27 object columns spent 105 ms of 106 ms there. The read is now memoized per node frame (identity plus a length/columns fingerprint, the resident indexes' contract) and cleared by `gfql_clear_caches()`; the same lookup now takes 2 ms (#2029).
19+
### Performance
20+
21+
* GFQL: an undirected `hop()` on pandas and cuDF hash-deduplicated the doubled edge frame (both orientations, 2E rows) on (from, to, edge id) before traversing, once per hop call and three times per chain; every consumer already dedups on the edge id, so the frame is now the plain concat the polars hop uses. Measured locally and not published: the block itself went from 7.1 s to 1.4 s on an 8M-edge frame on pandas and 50 ms to 17 ms on cuDF; the benchmark numbers are re-measured in pyg-bench.
22+
1923
### Fixed
2024

2125
* GFQL: undirected multi-hop traversals with a seeded wavefront were ~30x slower than before 0.58 because the wavefront seed-rediscovery rule (a seed is returned only when an edge-disjoint walk re-encounters it) ran as a per-edge Python loop on every undirected hop step. The rule now runs as joins and group-bys inside the caller's own frame engine (pandas, cuDF, polars), with the same results (#2023).

bin/test-polars.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ POLARS_TEST_FILES=(
8585
graphistry/tests/compute/gfql/test_seed_rediscovery_2023.py
8686
graphistry/tests/compute/gfql/test_hop_scaling_pin.py
8787
graphistry/tests/compute/gfql/test_seeded_node_lookup_fastpath.py
88+
graphistry/tests/compute/gfql/test_undirected_pairs_2026.py
8889
# #1882/#1913-f4/#1879 crash-family pins: the polars params (filter helpers on polars
8990
# frames, polars prune_self_edges, nodes-only typed-decline advice) only run here
9091
graphistry/tests/compute/gfql/test_crash_family_1882_1879.py

graphistry/compute/hop.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,11 +535,12 @@ def _build_pairs(src_col: str, dst_col: str) -> DataFrameT:
535535
elif direction == 'reverse':
536536
pairs = _build_pairs(g2._destination, g2._source)
537537
else:
538+
# both orientations, as the polars twin does; consumers dedup on EDGE_ID
538539
pairs = concat(
539540
[_build_pairs(g2._source, g2._destination), _build_pairs(g2._destination, g2._source)],
540541
ignore_index=True,
541542
sort=False,
542-
).drop_duplicates(subset=[FROM_COL, TO_COL, EDGE_ID])
543+
)
543544

544545
if fast_path_enabled and not skip_full_loop:
545546
frontier_ids = _domain_unique(traversal_seeds[node_col])
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Undirected hop builds both edge orientations without deduplicating the doubled frame.
2+
3+
Every consumer of the doubled frame dedups on the edge id, so the whole-frame dedup was
4+
redundant. Pins: parity with an independent oracle on a graph with self-loops, parallel
5+
edges and hub seeds on every engine; self-loops kept once through the multi-hop path; and
6+
categorical endpoint columns with differing category sets keep working.
7+
"""
8+
import numpy as np
9+
import pandas as pd
10+
import pytest
11+
12+
import graphistry
13+
from graphistry.compute.ast import e_undirected, n
14+
from graphistry.compute.predicates.is_in import is_in
15+
16+
ENGINES = ["pandas", "polars", "cudf"]
17+
18+
19+
def _frames(seed=0):
20+
rng = np.random.default_rng(seed)
21+
N, E = 3000, 12000
22+
edges = pd.DataFrame({"s": rng.integers(0, N, E), "d": rng.integers(0, N, E)})
23+
edges.loc[:40, "d"] = edges.loc[:40, "s"] # self-loops, some on hub seeds
24+
edges = pd.concat([edges, edges.iloc[:30]], ignore_index=True) # parallel edges
25+
nodes = pd.DataFrame({"id": np.arange(N)})
26+
return nodes, edges
27+
28+
29+
def _graph(engine):
30+
nodes, edges = _frames()
31+
if engine == "polars":
32+
pl = pytest.importorskip("polars")
33+
nodes, edges = pl.from_pandas(nodes), pl.from_pandas(edges)
34+
elif engine == "cudf":
35+
cudf = pytest.importorskip("cudf")
36+
nodes, edges = cudf.from_pandas(nodes), cudf.from_pandas(edges)
37+
return graphistry.nodes(nodes, "id").edges(edges, "s", "d")
38+
39+
40+
def _oracle_ball(edges, seeds, hops):
41+
"""Node set within `hops` undirected steps of the seeds, by plain set expansion."""
42+
adj = {}
43+
for s, d in zip(edges["s"].tolist(), edges["d"].tolist()):
44+
adj.setdefault(s, set()).add(d)
45+
adj.setdefault(d, set()).add(s)
46+
frontier, seen = set(seeds), set(seeds)
47+
for _ in range(hops):
48+
nxt = set()
49+
for u in frontier:
50+
nxt |= adj.get(u, set())
51+
frontier = nxt - seen
52+
seen |= nxt
53+
return seen
54+
55+
56+
def _ids(res):
57+
nodes = res._nodes
58+
df = nodes.to_pandas() if hasattr(nodes, "to_pandas") else pd.DataFrame(nodes)
59+
return set(int(v) for v in df["id"].tolist())
60+
61+
62+
@pytest.mark.parametrize("engine", ENGINES)
63+
@pytest.mark.parametrize("hops", [1, 2])
64+
def test_undirected_hop_matches_set_oracle_with_self_loops_and_parallel_edges(engine, hops):
65+
nodes, edges = _frames()
66+
deg = pd.concat([edges["s"], edges["d"]]).value_counts()
67+
seeds = [int(v) for v in deg.index[:5]]
68+
seeds.append(int(edges.loc[0, "s"])) # a seed carrying a self-loop
69+
g = _graph(engine)
70+
got = g.gfql([n({"id": is_in(seeds)}), e_undirected(hops=hops), n()], engine=engine)
71+
expected = _oracle_ball(edges, seeds, hops)
72+
# the wavefront keeps a seed only when an edge reaches it; the oracle includes every seed
73+
assert _ids(got) - set(seeds) == expected - set(seeds)
74+
assert _ids(got) <= expected
75+
76+
77+
@pytest.mark.parametrize("engine", ENGINES)
78+
@pytest.mark.parametrize("hops", [1, 2])
79+
def test_undirected_edges_keep_every_self_loop_once(engine, hops):
80+
"""hops=2 goes through the doubled-frame build; hops=1 is served by the seeded lane."""
81+
nodes, edges = _frames()
82+
loop_seed = int(edges.loc[0, "s"])
83+
g = _graph(engine)
84+
out = g.gfql([n({"id": is_in([loop_seed])}), e_undirected(hops=hops), n()], engine=engine)
85+
e = out._edges
86+
e = e.to_pandas() if hasattr(e, "to_pandas") else pd.DataFrame(e)
87+
loops = e[(e["s"] == loop_seed) & (e["d"] == loop_seed)]
88+
expected = edges[(edges["s"] == loop_seed) & (edges["d"] == loop_seed)]
89+
assert len(loops) == len(expected)
90+
# every returned edge row is one input edge row (parallel edges included): the
91+
# reverse orientation never surfaces as an extra row
92+
counts = e.groupby(["s", "d"]).size()
93+
source = edges.groupby(["s", "d"]).size()
94+
assert all(counts[k] <= source[k] for k in counts.index)
95+
96+
97+
@pytest.mark.parametrize("engine", ["pandas"]) # cuDF cannot concat categoricals of differing category sets
98+
def test_categorical_endpoints_with_different_category_sets(engine):
99+
edges = pd.DataFrame({"s": ["a", "b", "b", "a"], "d": ["b", "c", "b", "d"]}).astype(
100+
{"s": "category", "d": "category"})
101+
nodes = pd.DataFrame({"id": list("abcd")})
102+
if engine == "cudf":
103+
cudf = pytest.importorskip("cudf")
104+
nodes, edges = cudf.from_pandas(nodes), cudf.from_pandas(edges)
105+
g = graphistry.nodes(nodes, "id").edges(edges, "s", "d")
106+
seeds = pd.DataFrame({"id": ["a"]})
107+
if engine == "cudf":
108+
import cudf
109+
seeds = cudf.from_pandas(seeds)
110+
out = g.hop(nodes=seeds, hops=2, direction="undirected")
111+
assert _ids_str(out) == {"a", "b", "c", "d"}
112+
assert len(out._edges) == 4
113+
114+
115+
def _ids_str(res):
116+
nodes = res._nodes
117+
df = nodes.to_pandas() if hasattr(nodes, "to_pandas") else pd.DataFrame(nodes)
118+
return set(str(v) for v in df["id"].tolist())

0 commit comments

Comments
 (0)