Skip to content

Commit 6b83102

Browse files
authored
Merge pull request #1766 from graphistry/perf/gfql-seeded-projection-slice
perf(gfql): seeded fast path covers single-alias property RETURNs — LDBC IS5 shape (#1755)
2 parents bde4014 + 81a775f commit 6b83102

3 files changed

Lines changed: 264 additions & 7 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 fast path covers single-alias property RETURNs (LDBC IS5 shape) (#1755)**: `MATCH (m {id})-[:T]->(p) RETURN p.a AS x, p.b` — which lowers to `rows(source=p)+select(items)` rather than a whole-row projection — now takes the seeded typed-hop fast path: the deduped destination rows are renamed/selected directly (value-identical to the rows-pivot + select pipeline; row order may differ). Dtype parity with the full pandas path is exact: non-id properties ride the rows-pivot there, which upcasts int/float to float64 and bool to object, so the lean tail applies the same casts (the id property keeps its dtype); dtype classes not verified against the pivot (datetimes, pandas extension dtypes, categoricals) decline. `res._edges` is the same empty edges frame the full path yields (never None). Conservative declines keep full-path semantics for everything else: cross-alias refs (`RETURN m.x, p.y`), mixed whole-row+property, DISTINCT/ORDER BY/LIMIT (extra lowered ops), expr items, properties absent from the node frame (the full path's null/error semantics must apply), and requested-vs-actual engine mismatches (the full path converts to the requested engine). Differential fast-vs-full sweep across all of the above on pandas + polars: exact parity incl. dtypes and edges, engagement pinned both ways. Measured on LDBC SNB SF1 (dgx, harness row-validated): `message-creator` (IS5) 116.3→38.3 ms on polars (the residue is the seed scan, targeted separately by resident-index coverage).
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 two-star grouped-count OLAP queries collect once on polars (#1755)**: profiling showed the residual two-star path (graph-benchmark q5-q7 shapes) spent ~72% of its runtime on the fixed per-op cost of ~27 eager polars operations. When every residual translates natively (see the entry below), the whole plan — base filter_dicts, residual filters, typed-edge filters, semi-joins, group-prop lookup — now composes as ONE `pl.LazyFrame` plan collected once at the join (`filter_by_dict_polars` gains a `filter_expr_by_dict_polars` twin: same column/dtype resolution and typed-error/NIE contract, expression only). Value-identical to the eager lane it replaces (same filters/joins/aggregation), INCLUDING the empty-match boundary: the eager all-left-counts==1 shortcut's single `n=0` row (the openCypher count over no rows) is reproduced — the left-counts frame rides the same single collect. Edges are engine-converted before going lazy, so pandas frames under `engine='polars'` (the WITH..MATCH reentry shape) take the lane instead of crashing; LazyFrame inputs and untranslatable residuals decline to the eager path. Measured on dgx vs warm Kuzu, same session: q5 7.7→3.8ms (flips to a GFQL win vs 5.2), q6 9.8→6.1 (flips vs 7.3), q7 8.7→5.7 (flips vs 6.9) — moving the graph-benchmark scoreboard from 4W/2T/3L to 7W/1T/1L. Differential tests spy the extracted `_connected_join_two_star_fused_polars` helper and ASSERT lane engagement (a `count(*)` query declines the whole two-star path — pinned as a decline shape): fused vs forced-eager exact-row parity (ORDER BY pinned), the ungrouped empty-match n=0 row, pandas-frames-under-polars-engine parity, empty-result shape, pandas oracle.

graphistry/compute/gfql_fast_paths.py

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2036,21 +2036,33 @@ def _execute_seeded_typed_hop_fast_path(
20362036
if requested_engine not in (Engine.PANDAS, Engine.CUDF, Engine.POLARS, Engine.POLARS_GPU):
20372037
return None
20382038
projection = compiled_query.result_projection
2039-
if projection is None or projection.table != "nodes":
2039+
if projection is not None and projection.table != "nodes":
20402040
return None
20412041
# Only a single whole-row node alias (RETURN p). Multi-alias returns (RETURN
20422042
# m, p) combine aliases into one row per match — different shape — so bail.
2043-
proj_cols = projection.columns
2044-
if len(proj_cols) != 1 or proj_cols[0].kind != "whole_row":
2043+
# (Single-alias PROPERTY returns lower with result_projection=None + a select
2044+
# op and are covered by the select-shape branch below.)
2045+
proj_cols = () if projection is None else projection.columns
2046+
if projection is not None and (len(proj_cols) != 1 or proj_cols[0].kind != "whole_row"):
20452047
return None
20462048
if compiled_query.execution_extras is not None and (
20472049
compiled_query.execution_extras.connected_match_join is not None
20482050
or compiled_query.execution_extras.connected_optional_match is not None
20492051
):
20502052
return None
20512053
ops = list(compiled_query.chain.chain)
2052-
if len(ops) != 4:
2053-
return None
2054+
select_op: Optional[ASTCall] = None
2055+
if projection is not None:
2056+
if len(ops) != 4:
2057+
return None
2058+
else:
2059+
# property-RETURN lowering: [n0, e1, n2, rows(source=alias), select(items)]
2060+
# (the LDBC IS5 shape: RETURN p.a AS x, p.b). Anything else (ORDER BY /
2061+
# LIMIT / DISTINCT add further ops; exprs lower differently) falls back.
2062+
if len(ops) != 5 or not isinstance(ops[4], ASTCall) or ops[4].function != "select":
2063+
return None
2064+
select_op = ops[4]
2065+
ops = ops[:4]
20542066
n0, e1, n2, call = ops
20552067
if not (isinstance(n0, ASTNode) and isinstance(e1, ASTEdge)
20562068
and isinstance(n2, ASTNode) and isinstance(call, ASTCall)):
@@ -2071,8 +2083,31 @@ def _execute_seeded_typed_hop_fast_path(
20712083
# source node (n0) — the forward seeded shape MATCH (m {id})-[:T]->(p) RETURN p.
20722084
# Other alias/seed placements (e.g. reverse patterns where the seed is on the
20732085
# RETURN node) fall back to the full path.
2074-
if n2._name != projection.alias:
2086+
return_alias = projection.alias if projection is not None else str((call.params or {}).get("source", ""))
2087+
if n2._name != return_alias:
20752088
return None
2089+
select_items: Optional[list] = None
2090+
if select_op is not None:
2091+
raw_items = (select_op.params or {}).get("items")
2092+
if not raw_items or not isinstance(raw_items, (list, tuple)):
2093+
return None
2094+
nodes_frame_cols = None if base_graph._nodes is None else set(map(str, base_graph._nodes.columns))
2095+
if nodes_frame_cols is None:
2096+
return None
2097+
prefix = f"{return_alias}."
2098+
select_items = []
2099+
for it in raw_items:
2100+
if not (isinstance(it, (list, tuple)) and len(it) == 2):
2101+
return None
2102+
out_name, src_ref = str(it[0]), str(it[1])
2103+
# only same-alias property refs; the bare property must exist on the
2104+
# node frame (absent -> full path's null/error semantics must apply)
2105+
if not src_ref.startswith(prefix):
2106+
return None
2107+
prop = src_ref[len(prefix):]
2108+
if "." in prop or prop not in nodes_frame_cols:
2109+
return None
2110+
select_items.append((out_name, prop))
20762111
if not (n0.filter_dict and any(not str(k).startswith("label__") for k in n0.filter_dict)):
20772112
return None # n0 must carry a selective (non-label) seed
20782113
direction = e1.direction
@@ -2088,11 +2123,59 @@ def _execute_seeded_typed_hop_fast_path(
20882123
is_polars = is_polars_df(nodes_frame)
20892124
if is_polars != is_polars_df(base_graph._edges):
20902125
return None # mixed-engine node/edge frames: decline, full path decides
2126+
if select_items is not None and (requested_engine in (Engine.POLARS, Engine.POLARS_GPU)) != is_polars:
2127+
# Requested-vs-actual engine mismatch (e.g. polars frames + engine='pandas'):
2128+
# the full path CONVERTS the result to the requested engine, so the lean
2129+
# projection would leak actual-engine frames. Decline; the full path decides.
2130+
return None
20912131
helper = _seeded_typed_return_dst_polars if is_polars else _seeded_typed_return_dst_pandas_cudf
20922132
dst_res = helper(base_graph, n0, n2, e1, src, dst, node, direction)
20932133
if dst_res is None:
20942134
return None
20952135
p_rows, _edges = dst_res
2136+
if select_items is not None:
2137+
# Lean property projection (IS5 shape): the deduped destination rows carry
2138+
# the raw property columns — rename/select directly, same values the
2139+
# rows-pivot + select pipeline emits (row order may differ; documented
2140+
# value-identical contract).
2141+
if is_polars:
2142+
import polars as pl
2143+
out_frame = p_rows.select([pl.col(prop).alias(out) for out, prop in select_items])
2144+
else:
2145+
# dtype parity with the full path: non-id properties ride the rows-pivot,
2146+
# which upcasts int/float -> float64 and bool -> object; the node-id
2147+
# property passes through the pivot key and keeps its dtype. Any dtype
2148+
# class not verified against the pivot (datetimes, pandas extension
2149+
# dtypes like nullable Int64/StringDtype, categoricals) declines to the
2150+
# full path rather than risk a silent dtype divergence.
2151+
import numpy as np
2152+
casts: Dict[str, str] = {}
2153+
for out_name, prop in select_items:
2154+
if prop == node:
2155+
continue
2156+
d = p_rows[prop].dtype
2157+
if isinstance(d, pd.StringDtype):
2158+
continue # pandas>=3 default str dtype: pivot preserves it (verified parity)
2159+
if not isinstance(d, np.dtype):
2160+
return None
2161+
if d == np.dtype(bool):
2162+
casts[out_name] = "object"
2163+
elif d.kind in "iuf":
2164+
casts[out_name] = "float64"
2165+
elif d.kind != "O":
2166+
return None
2167+
out_frame = p_rows[[prop for _, prop in select_items]].copy()
2168+
out_frame.columns = [out for out, _ in select_items]
2169+
for out_name, target in casts.items():
2170+
out_frame[out_name] = out_frame[out_name].astype(target)
2171+
out = base_graph.bind()
2172+
out._nodes = out_frame
2173+
# full-path parity: a property RETURN yields an EMPTY edges frame with the
2174+
# edge schema (never None — res._edges must stay usable). The helper's
2175+
# edges are the matched hop edges, so take their zero-row head.
2176+
out._edges = _edges.head(0)
2177+
return out
2178+
assert projection is not None # narrowed by the gate above
20962179
# Lean projection: p_rows already IS the RETURN-alias (destination) node set.
20972180
# Tag with the alias and reuse apply_result_projection for the exact
20982181
# column-order/flatten semantics — all on a handful of rows, so seeded cypher

0 commit comments

Comments
 (0)