Skip to content

Commit 84be35f

Browse files
authored
Merge pull request #1777 from graphistry/perf/gfql-node-property-index
perf(gfql): secondary node property indexes for seeded traversal
2 parents a8c3e82 + 0549beb commit 84be35f

15 files changed

Lines changed: 942 additions & 227 deletions

File tree

CHANGELOG.md

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

11+
### Added
12+
- **GFQL secondary node property indexes (`create_index('node_prop', column=...)` / `g.gfql_index_node_props([...])`)**: a seed predicate on a NON-key column — `MATCH (m {id: 42})` where the graph's node id binding is some other column — previously cost a full node scan, because the registry only indexed the node-id binding and the CSR adjacencies. A property index is the same pay-as-you-go sidecar as the existing kinds: sorted distinct values over node **row positions** (CSR, so duplicate values are indexable), never reorders `.nodes`, fingerprint-validated so a `.nodes()` rebind is treated as absent (safe miss, never a wrong answer), engine-polymorphic (numpy host / cupy on-device), and policy-gated (`off`/`use`/`auto`/`force`). The seeded fixed-hop planner picks the **most selective** indexed scalar predicate in the seed filter using a free CSR-offset estimate, gathers those candidates, and applies every remaining predicate to them — so results are identical whether the index is present, absent, stale, or cost-gated out. `show_indexes()` lists property indexes; `drop_index('node_prop', column=...)` drops one. Only integer columns are indexable today (float NaN ordering, strings on cupy, and nulls all decline to the scan); widening that is additive. **Perf (dgx-spark, official LDBC SNB SF1, 3.18M nodes, warm median, value-identical 19-row result):** interactive-short IS7 `71.6 ms -> 19.5 ms` (**3.7x**), with a one-time `112 ms` build — the seed lookup itself goes from a `51.2 ms` scan to `0.096 ms`.
13+
1114
### Performance
1215
- **GFQL indexed fixed-hop bindings now dispatch BEFORE the canonical traversal (pandas / cuDF / native Polars)**: the resident-index fixed-hop path (`rows(binding_ops=...)`, the Cypher multi-alias `MATCH … RETURN` shape) was consulted only *inside* row materialization — after the engine had already run the full canonical traversal — so its compact path bag was computed on top of the graph-sized work it exists to avoid. Both the pandas/cuDF chain boundary and the native Polars chain now ask the same shared, structural gate first and, only when it can serve the WHOLE middle exactly, skip the canonical traversal and hand the compact state to the unchanged row materializer. Engagement stays operator/index/dtype/cost based — no query, schema, or hop-count recognition — and every unsupported, seeded, prefiltered, policy-bearing, shortest-path, or cost-gated shape still declines to canonical execution with identical results. On a standard LDBC SNB SF1 interactive-short profile this removed the redundant two-hop typed-mask and 16-merge buckets and cut the profiled call ~11.9× (1263 ms → 106 ms), exact 19-row oracle preserved.
1316
- **Indexed seed lookup now covers a unique node id PLUS extra scalar constraints**: `{node_id: v, other: w}` previously fell back to a full node-table scan even though the unique node-id index can gather at most one row; it now gathers that one row and applies the remaining constraints to it. Missing ids and non-matching extra constraints return the same empty result as before, and duplicate-id graphs (which cannot build the index) are unaffected.

docs/source/gfql/indexing.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ carrying them:
3939
- Sorted node-id lookup: seed-row and endpoint materialization become positional
4040
gathers instead of ``O(N)`` scans. Requires unique node ids —
4141
``gfql_index_all()`` silently skips it otherwise (adjacency is still built).
42+
* - ``node_prop``
43+
- Sorted lookup on a node **property** column (a secondary index): a seed
44+
predicate like ``MATCH (m {id: 42})`` on a column that is not the node-id
45+
binding becomes a positional gather instead of an ``O(N)`` scan. Duplicate
46+
values are fine (all matching rows are gathered). Integer columns without
47+
nulls only — anything else declines to the scan. Opt-in per column.
4248

4349
They are **sidecars over row positions**: your ``.edges`` / ``.nodes`` frames are never
4450
reordered or copied, and the resident footprint is visible per index via
@@ -93,12 +99,36 @@ API):
9399
g = g.gfql_index_all() # out+in adjacency + node_id (the one-liner)
94100
g = g.gfql_index_edges("forward") # or just one direction: 'forward'|'reverse'|'both'
95101
g = g.create_index("edge_out_adj") # or one kind: 'edge_out_adj'|'edge_in_adj'|'node_id'
102+
g = g.gfql_index_node_props(["id"]) # secondary indexes on node property columns
96103
g.show_indexes() # pandas DataFrame: kind, engine, n_keys, nbytes, valid
97104
g = g.drop_index() # drop all (or drop_index("edge_out_adj"))
98105
99106
Unlike ``gfql_index_all()``, an explicit ``create_index("node_id")`` **raises** on
100107
non-unique node ids rather than skipping.
101108

109+
Seeding on a property (secondary index)
110+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
111+
112+
The ``node_id`` index covers the column bound as the node id. A query that seeds on
113+
a *different* column — a business key such as ``MATCH (m {id: 42})`` when the graph
114+
is keyed by something else — otherwise scans the whole node table to find its seed.
115+
``node_prop`` indexes that column instead:
116+
117+
.. code-block:: python
118+
119+
g = g.gfql_index_all().gfql_index_node_props(["id"]) # skips unindexable columns
120+
g = g.create_index("node_prop", column="id") # or one column, raising if it cannot
121+
122+
# equivalently over the Cypher DDL / JSON surfaces
123+
g.gfql('CREATE GFQL INDEX FOR node_prop ON id')
124+
g = g.drop_index("node_prop", column="id") # or drop_index("node_prop") for all
125+
126+
When several indexed columns appear in one seed predicate, the planner gathers on the
127+
**most selective** one (estimated for free from the index's own offsets) and applies
128+
the remaining predicates to those candidates, so results never depend on which index
129+
happens to be resident. As with every kind, a missing, stale, or cost-gated-out index
130+
simply falls back to the scan.
131+
102132
What uses the index today
103133
-------------------------
104134

graphistry/compute/ComputeMixin.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import numpy as np
22
import pandas as pd
3-
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
3+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union
44
from typing_extensions import Literal
55
from graphistry.Engine import Engine, EngineAbstract, EngineAbstractType, POLARS_ENGINES, resolve_engine, df_to_engine, df_concat, safe_merge
66
from graphistry.Plottable import Plottable
@@ -29,6 +29,7 @@
2929
)
3030

3131
if TYPE_CHECKING:
32+
from graphistry.compute.gfql.index.types import IndexKind
3233
from graphistry.compute.gfql.index.explain import GfqlExplainReport
3334
from graphistry.compute.gfql.index.policy import IndexPolicy
3435
from graphistry.compute.gfql.query_types import GFQLQuery
@@ -638,10 +639,10 @@ def create_index(self, kind, *, column=None, name=None, engine='auto'):
638639
from graphistry.compute.gfql.index import create_index as _ci
639640
return _ci(self, kind, column=column, name=name, engine=engine)
640641

641-
def drop_index(self, kind=None):
642-
"""Drop one resident GFQL index (by kind) or all (kind=None). Idempotent; returns a new Plottable."""
642+
def drop_index(self, kind: Optional['IndexKind'] = None, *, column: Optional[str] = None) -> 'Plottable':
643+
"""Drop one resident GFQL index (by kind, or one property index by column) or all (kind=None). Idempotent; returns a new Plottable."""
643644
from graphistry.compute.gfql.index import drop_index as _di
644-
return _di(self, kind)
645+
return _di(self, kind, column=column)
645646

646647
def show_indexes(self):
647648
"""Return a pandas DataFrame describing resident GFQL indexes (name, kind, column, valid). Empty if none; ``valid=False`` marks a stale index after a frame rebind."""
@@ -658,6 +659,15 @@ def gfql_index_all(self, engine='auto'):
658659
from graphistry.compute.gfql.index import gfql_index_all as _gia
659660
return _gia(self, engine=engine)
660661

662+
def gfql_index_node_props(self, columns: Sequence[str], engine: EngineAbstractType = 'auto') -> 'Plottable':
663+
"""Convenience: build node PROPERTY indexes for ``columns`` (secondary indexes).
664+
665+
A seed predicate on a non-key column (``{id: 42}`` when the graph's node id
666+
is some other column) otherwise costs a full node scan. Unindexable columns
667+
are skipped, keeping the correct scan path. Returns a new Plottable."""
668+
from graphistry.compute.gfql.index import gfql_index_node_props as _ginp
669+
return _ginp(self, columns, engine=engine)
670+
661671
def filter_nodes_by_dict(self, *args, **kwargs):
662672
return filter_nodes_by_dict_base(self, *args, **kwargs)
663673
filter_nodes_by_dict.__doc__ = filter_nodes_by_dict_base.__doc__

graphistry/compute/dataframe/join.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,124 @@ def _uniq(df: DataFrameT, col: str, name: str) -> DataFrameT:
294294
if right_keep:
295295
right_eval = right_eval[list(right_keep)]
296296
return left_eval, right_eval, mid_values
297+
298+
299+
def estimate_inner_join_rows(
300+
left: DataFrameT,
301+
right: DataFrameT,
302+
*,
303+
left_on: str,
304+
right_on: str,
305+
engine: Engine,
306+
) -> int:
307+
"""Rows an inner join WOULD emit, without materializing it.
308+
309+
Sum over matched keys of (left count * right count) — a group-by on each side
310+
instead of the join itself, so a planner can cost-gate a hop before paying for
311+
it.
312+
"""
313+
if len(left) == 0 or len(right) == 0:
314+
return 0
315+
left_n, right_n = "__gfql_join_left_n__", "__gfql_join_right_n__"
316+
if engine in POLARS_ENGINES:
317+
import polars as pl
318+
319+
left_counts = left.group_by(left_on).len().rename({"len": left_n}) # type: ignore[operator]
320+
right_counts = right.group_by(right_on).len().rename({"len": right_n}) # type: ignore[operator]
321+
value = (
322+
left_counts.join(right_counts, left_on=left_on, right_on=right_on, how="inner")
323+
.select((pl.col(left_n) * pl.col(right_n)).sum())
324+
.item()
325+
)
326+
return 0 if value is None else int(value)
327+
328+
left_counts = left.groupby(left_on, sort=False).size().reset_index()
329+
left_counts.columns = [left_on, left_n]
330+
right_counts = right.groupby(right_on, sort=False).size().reset_index()
331+
right_counts.columns = [right_on, right_n]
332+
counts = left_counts.merge(
333+
right_counts, left_on=left_on, right_on=right_on, how="inner", sort=False,
334+
)
335+
if len(counts) == 0:
336+
return 0
337+
return int((counts[left_n] * counts[right_n]).sum())
338+
339+
340+
def path_ordered_expand_join(
341+
state: DataFrameT,
342+
step: DataFrameT,
343+
*,
344+
current_col: str,
345+
from_col: str,
346+
to_col: str,
347+
path_order_col: str,
348+
tiebreak_cols: Sequence[str],
349+
alias: Optional[str],
350+
engine: Engine,
351+
) -> DataFrameT:
352+
"""Expand a path-bag by one step, preserving (path, tiebreak...) row order.
353+
354+
``state[current_col]`` joins ``step[from_col]``; ``step[to_col]`` becomes the new
355+
``current_col``. A synthetic ``path_order_col`` pins the incoming row order so the
356+
result is deterministic under any engine's join, and the bookkeeping columns are
357+
dropped on the way out. ``alias``, when given, also exposes the new current value
358+
under that name.
359+
"""
360+
drop_after = [from_col, path_order_col, *tiebreak_cols]
361+
if engine in POLARS_ENGINES:
362+
import polars as pl
363+
364+
joined = (
365+
state.with_row_index(path_order_col) # type: ignore[operator]
366+
.join(step, left_on=current_col, right_on=from_col, how="inner")
367+
.sort([path_order_col, *tiebreak_cols])
368+
.drop(current_col)
369+
.rename({to_col: current_col})
370+
)
371+
if isinstance(alias, str):
372+
joined = joined.with_columns(pl.col(current_col).alias(alias))
373+
return cast(
374+
DataFrameT,
375+
joined.drop([col for col in drop_after if col in joined.columns]),
376+
)
377+
378+
import numpy as np
379+
380+
out = state.assign(**{path_order_col: np.arange(len(state))}).merge(
381+
step, left_on=current_col, right_on=from_col, how="inner", sort=False,
382+
)
383+
if len(out):
384+
sort_cols = [path_order_col, *tiebreak_cols]
385+
out = (
386+
out.sort_values(sort_cols, kind="stable") if engine == Engine.PANDAS
387+
else out.sort_values(sort_cols)
388+
)
389+
out = out.drop(columns=[current_col]).rename(columns={to_col: current_col})
390+
if isinstance(alias, str):
391+
out = out.assign(**{alias: out[current_col]})
392+
return cast(
393+
DataFrameT,
394+
out.drop(columns=[col for col in drop_after if col in out.columns]),
395+
)
396+
397+
398+
def semijoin_by_column(
399+
frame: DataFrameT,
400+
keys: DataFrameT,
401+
*,
402+
left_on: str,
403+
right_on: str,
404+
engine: Engine,
405+
) -> DataFrameT:
406+
"""Rows of ``frame`` whose ``left_on`` value appears in ``keys[right_on]``."""
407+
if engine in POLARS_ENGINES:
408+
return cast(
409+
DataFrameT,
410+
frame.join( # type: ignore[call-arg]
411+
keys.select(right_on).unique(), # type: ignore[operator]
412+
left_on=left_on,
413+
right_on=right_on,
414+
how="semi", # type: ignore[arg-type]
415+
),
416+
)
417+
return cast(DataFrameT, frame[frame[left_on].isin(keys[right_on])])

graphistry/compute/gfql/index/__init__.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
seeded traversal.
33
44
Public surface (see api.py): ``create_index``, ``drop_index``, ``show_indexes``,
5-
``gfql_index_edges``, ``gfql_index_all``, and the planner entry ``maybe_index_hop``.
5+
``gfql_index_edges``, ``gfql_index_all``, ``gfql_index_node_props``, and the
6+
planner entry ``maybe_index_hop``.
67
These are wired onto Plottable via ComputeMixin.
78
"""
89
from .types import (
@@ -11,12 +12,15 @@
1112
)
1213
from .registry import (
1314
GfqlIndexRegistry, EMPTY_REGISTRY,
14-
EDGE_OUT_ADJ, EDGE_IN_ADJ, NODE_ID, ADJ_KINDS, ALL_KINDS,
15-
AdjacencyIndex, NodeIdIndex,
15+
EDGE_OUT_ADJ, EDGE_IN_ADJ, NODE_ID, NODE_PROP, ADJ_KINDS, ALL_KINDS,
16+
AdjacencyIndex, NodeIdIndex, NodePropIndex,
1617
)
1718
from .api import (
1819
create_index, drop_index, show_indexes, gfql_index_edges, gfql_index_all,
19-
get_registry, set_registry, get_index_policy, maybe_index_hop, index_name, index_trace,
20+
gfql_index_node_props,
21+
GfqlIndexUnsupportedError,
22+
get_registry, set_registry, get_index_policy, with_index_policy, maybe_index_hop,
23+
index_name, index_trace,
2024
)
2125
from .wire import (
2226
CreateIndex, DropIndex, ShowIndexes, IndexOp, apply_index_op, index_op_from_json,
@@ -30,10 +34,11 @@
3034
"AdjacencyIndexKind", "ArrayLike", "ArrayNamespace", "EdgeIndexDirection",
3135
"HopDirection", "IndexBackend", "IndexKind", "IndexTraceStep",
3236
"GfqlIndexRegistry", "EMPTY_REGISTRY",
33-
"EDGE_OUT_ADJ", "EDGE_IN_ADJ", "NODE_ID", "ADJ_KINDS", "ALL_KINDS",
34-
"AdjacencyIndex", "NodeIdIndex",
37+
"EDGE_OUT_ADJ", "EDGE_IN_ADJ", "NODE_ID", "NODE_PROP", "ADJ_KINDS", "ALL_KINDS",
38+
"AdjacencyIndex", "NodeIdIndex", "NodePropIndex",
3539
"create_index", "drop_index", "show_indexes", "gfql_index_edges",
36-
"gfql_index_all", "get_registry", "set_registry", "get_index_policy", "maybe_index_hop", "index_name",
40+
"gfql_index_all", "gfql_index_node_props", "GfqlIndexUnsupportedError",
41+
"get_registry", "with_index_policy", "set_registry", "get_index_policy", "maybe_index_hop", "index_name",
3742
"index_trace",
3843
"CreateIndex", "DropIndex", "ShowIndexes", "IndexOp", "apply_index_op",
3944
"index_op_from_json", "is_index_op", "is_index_op_json",

0 commit comments

Comments
 (0)