|
| 1 | +"""Scaling pin for undirected multi-hop (#2023 / #2026 family). |
| 2 | +
|
| 3 | +Wall-clock thresholds are flaky in CI, so this pins a RATIO: an undirected 2-hop chain |
| 4 | +from hub seeds must cost no more than a fixed multiple of two plain frame joins that |
| 5 | +traverse the same edges. A per-edge interpreter loop (the #2023 defect) blew this ratio |
| 6 | +to ~50x; the engine-native rule keeps it under ~20x. The graph is small enough for |
| 7 | +the core lanes; polars runs when installed. |
| 8 | +""" |
| 9 | +import time |
| 10 | + |
| 11 | +import numpy as np |
| 12 | +import pandas as pd |
| 13 | +import pytest |
| 14 | + |
| 15 | +import graphistry |
| 16 | +from graphistry.compute.ast import e_undirected, n |
| 17 | +from graphistry.compute.predicates.is_in import IsIn |
| 18 | + |
| 19 | +#: Measured on the same graph: the 0.59.0 tree (per-edge loop, #2023) 47-52x on both engines; |
| 20 | +#: the engine-native rule 20x (pandas) / 6x (polars). 30 sits between the two populations. |
| 21 | +MAX_RATIO = 30.0 |
| 22 | +N_NODES = 200_000 |
| 23 | +N_EDGES = 1_500_000 |
| 24 | +SEEDS = 50 |
| 25 | + |
| 26 | + |
| 27 | +def _graph(): |
| 28 | + # heavy-tailed degrees (hubs), so a 2-hop from the top seeds touches most edges, |
| 29 | + # which is the shape that exposed #2023 on LiveJournal |
| 30 | + rng = np.random.default_rng(2023) |
| 31 | + src = (rng.zipf(1.3, N_EDGES) - 1) % N_NODES |
| 32 | + dst = rng.integers(0, N_NODES, N_EDGES) |
| 33 | + edges = pd.DataFrame({"s": src, "d": dst}) |
| 34 | + degree = pd.concat([edges["s"], edges["d"]]).value_counts() |
| 35 | + nodes = pd.DataFrame({"id": degree.index, "degree": degree.values}) |
| 36 | + seeds = degree.index[:SEEDS].tolist() |
| 37 | + return edges, nodes, seeds |
| 38 | + |
| 39 | + |
| 40 | +def _baseline_two_joins(edges: pd.DataFrame, seeds) -> int: |
| 41 | + """Two undirected frontier expansions as plain merges: the work floor for a 2-hop.""" |
| 42 | + both = pd.concat([edges, edges.rename(columns={"s": "d", "d": "s"})], ignore_index=True) |
| 43 | + frontier = pd.DataFrame({"s": seeds}) |
| 44 | + seen = set(seeds) |
| 45 | + for _ in range(2): |
| 46 | + nxt = both.merge(frontier, on="s")["d"].drop_duplicates() |
| 47 | + seen.update(nxt.tolist()) |
| 48 | + frontier = pd.DataFrame({"s": nxt}) |
| 49 | + return len(seen) |
| 50 | + |
| 51 | + |
| 52 | +def _best_of(fn, runs=2): |
| 53 | + best = float("inf") |
| 54 | + for _ in range(runs): |
| 55 | + t0 = time.perf_counter() |
| 56 | + fn() |
| 57 | + best = min(best, time.perf_counter() - t0) |
| 58 | + return best |
| 59 | + |
| 60 | + |
| 61 | +try: |
| 62 | + import polars # noqa: F401 |
| 63 | + HAS_POLARS = True |
| 64 | +except ImportError: # pragma: no cover |
| 65 | + HAS_POLARS = False |
| 66 | + |
| 67 | + |
| 68 | +@pytest.mark.parametrize("engine", [ |
| 69 | + "pandas", |
| 70 | + pytest.param("polars", marks=pytest.mark.skipif(not HAS_POLARS, reason="polars not installed")), |
| 71 | +]) |
| 72 | +def test_undirected_two_hop_costs_a_bounded_multiple_of_two_joins(engine): |
| 73 | + edges, nodes, seeds = _graph() |
| 74 | + if engine == "polars": |
| 75 | + import polars as pl |
| 76 | + g = graphistry.edges(pl.from_pandas(edges), "s", "d").nodes(pl.from_pandas(nodes), "id") |
| 77 | + else: |
| 78 | + g = graphistry.edges(edges, "s", "d").nodes(nodes, "id") |
| 79 | + query = [n({"id": IsIn(options=seeds)}), e_undirected(to_fixed_point=False, hops=2), n()] |
| 80 | + g.gfql(query, engine=engine) # warm |
| 81 | + hop_s = _best_of(lambda: g.gfql(query, engine=engine)) |
| 82 | + base_s = _best_of(lambda: _baseline_two_joins(edges, seeds)) |
| 83 | + ratio = hop_s / base_s |
| 84 | + print(f"[scaling-pin] {engine}: 2-hop {hop_s * 1000:.0f} ms, " |
| 85 | + f"two joins {base_s * 1000:.0f} ms, ratio {ratio:.1f}x") |
| 86 | + assert ratio < MAX_RATIO, ( |
| 87 | + f"{engine}: 2-hop took {hop_s * 1000:.0f} ms = {ratio:.0f}x two plain joins " |
| 88 | + f"({base_s * 1000:.0f} ms); a per-edge interpreter loop is back (see #2023)") |
0 commit comments