Skip to content

Commit bde4014

Browse files
authored
Merge pull request #1764 from graphistry/perf/gfql-olap-q5-filter-dedup
perf(gfql): fuse residual two-star grouped-count into one lazy collect — q5/q6/q7 flip to wins (#1755)
2 parents 0bb7761 + fe8cbcf commit bde4014

4 files changed

Lines changed: 314 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1111
### Performance
1212
- **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`.
1313
- **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`.
14+
- **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.
1415
- **GFQL connected-join simple residuals filter natively on polars (#1729/#1755)**: on the polars engines, a connected-join node residual of the #1729 scalar shapes — case-insensitive equality ``(tolower(a.col) = tolower('lit'))`` and scalar comparisons ``(a.col <op> literal)`` for ``= >= <= > <`` — previously dispatched a full sub-``chain()`` per alias (the polars row pipeline has no native ``where_rows``), costing several ms per residual on OLAP group-aggregate queries. `_residual_polars_expr` now translates these shapes to native ``pl.Expr`` filters applied directly to the alias frame (graph-benchmark q5 10.2→6.6ms, q6 10.7→8.9ms on dgx). Null semantics match the ``where_rows`` evaluator (null comparisons drop the row); any unrecognized shape, alias mismatch, or absent column falls back to the existing chain path — and if *any* expr in a residual group fails to translate, the whole group falls back (never a partial mix). Byte-parity verified across an 8-case adversarial differential (nulls, unicode/casefold, numeric ranges, mixed residuals) plus the full suite.
1516
- **GFQL seeded-chain lean combine (#1755)**: The two-pass chain executor (`_chain_impl` backward pass + `combine_steps`) reconciled wavefront boundaries with full-node-frame `safe_merge`/`pandas.merge` calls even when the seeded result was a single row, so a seeded 1-hop paid a whole-frame join. When the small side is at least `4x` smaller than the full frame (and is unique on the id with no extra columns), the byte-identical result is now obtained by an `isin` membership filter instead of the join machinery. Gated narrowly on cardinality/uniqueness/column-set and pandas-only (cuDF's GPU hash-join is already sub-ms; polars takes its own engine path); `GFQL_LEAN_COMBINE=0` restores the legacy merges. Parity is byte-identical (lean-on vs lean-off) across seeded node lookup, seeded 1-hop expand, and the native typed chain, including a null-key equivalence guard.
1617

graphistry/compute/gfql/lazy/engine/polars/predicates.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,10 +309,22 @@ def _mismatch(v: Any) -> bool:
309309

310310
def filter_by_dict_polars(df: "pl.DataFrame", filter_dict: "Optional[Dict[str, Any]]") -> "pl.DataFrame":
311311
"""Return rows of polars ``df`` matching all entries in ``filter_dict`` via one filter."""
312+
combined = filter_expr_by_dict_polars(df, filter_dict)
313+
if combined is None:
314+
return df
315+
return df.filter(combined)
316+
317+
318+
def filter_expr_by_dict_polars(df: "pl.DataFrame", filter_dict: "Optional[Dict[str, Any]]") -> "Optional[pl.Expr]":
319+
"""Build the combined boolean ``pl.Expr`` filter_by_dict_polars would apply, or None
320+
for an empty/absent filter dict. ``df`` supplies the schema for column/dtype
321+
resolution only — callers may apply the expr to a LazyFrame over the same schema
322+
(the fused connected-join lane), with identical semantics incl. the same typed
323+
error/NIE contract for unsupported shapes."""
312324
import polars as pl
313325

314326
if not filter_dict:
315-
return df
327+
return None
316328

317329
exprs: "List[pl.Expr]" = []
318330
for col, val in filter_dict.items():
@@ -373,8 +385,8 @@ def filter_by_dict_polars(df: "pl.DataFrame", filter_dict: "Optional[Dict[str, A
373385
exprs.append(pl.col(resolved_col) == resolved_val)
374386

375387
if not exprs:
376-
return df
388+
return None
377389
combined = exprs[0]
378390
for e in exprs[1:]:
379391
combined = combined & e
380-
return df.filter(combined)
392+
return combined

graphistry/compute/gfql_fast_paths.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,141 @@ def _connected_join_filter_node_frames_by_residuals(
767767
return out
768768

769769

770+
771+
def _connected_join_two_star_fused_polars(
772+
nodes: DataFrameT,
773+
edges: DataFrameT,
774+
*,
775+
node_col: str,
776+
src_col: str,
777+
dst_col: str,
778+
residual_map: Dict[str, List[Any]],
779+
shared_alias: str,
780+
first_end_alias: str,
781+
second_end_alias: str,
782+
first_start_fd: Optional[dict],
783+
second_start_fd: Optional[dict],
784+
first_end_fd: Optional[dict],
785+
second_end_fd: Optional[dict],
786+
first_edge_match: Optional[dict],
787+
second_edge_match: Optional[dict],
788+
group_prop_refs: List[Tuple[str, str]],
789+
output_group_keys: List[str],
790+
agg_alias: str,
791+
order_keys: List[Tuple[str, bool]],
792+
limit_value: Optional[int],
793+
select_items: Optional[List[Tuple[str, str]]],
794+
) -> Optional[DataFrameT]:
795+
"""FUSED lazy lane (#1755 lane-1): the whole two-star grouped-count as ONE lazy
796+
plan, collected once at the join (the eager path pays a fixed collect cost per
797+
op; ~27 collects/exec dominated q5-q7 profiles). Value-identical to the eager
798+
lane -- same filters/semi-joins/aggregation, and the empty-match boundary
799+
reproduces the eager all-left-counts==1 shortcut's single n=0 row (openCypher
800+
count over no rows). Returns None to decline (untranslatable residual, missing
801+
group property) so the caller falls through to the eager path. Both frames must
802+
already be engine-converted polars frames.
803+
"""
804+
import polars as pl
805+
from graphistry.compute.gfql.lazy.engine.polars.predicates import filter_expr_by_dict_polars
806+
807+
if isinstance(nodes, pl.LazyFrame) or isinstance(edges, pl.LazyFrame):
808+
return None # eager lane owns LazyFrame inputs (schema probes on a LazyFrame warn/cost)
809+
frame_aliases = {shared_alias, first_end_alias, second_end_alias}
810+
node_schema = dict(nodes.schema)
811+
residual_exprs: Dict[str, List["pl.Expr"]] = {}
812+
for r_alias, r_exprs in residual_map.items():
813+
if r_alias not in frame_aliases:
814+
continue # mirror the eager path: residuals for unbound aliases are not applied here
815+
r_translated = [_residual_polars_expr(e, r_alias, node_schema) for e in r_exprs]
816+
if any(t is None for t in r_translated):
817+
return None
818+
residual_exprs[r_alias] = [t for t in r_translated if t is not None]
819+
for _, prop in group_prop_refs:
820+
if prop not in nodes.columns:
821+
return None
822+
lf_nodes = nodes.lazy()
823+
lf_edges = edges.lazy()
824+
825+
def _alias_nodes_lf(fds: List[Optional[dict]], r_alias: str) -> "pl.LazyFrame":
826+
lf = lf_nodes
827+
for fd in fds:
828+
fe = filter_expr_by_dict_polars(nodes, fd)
829+
if fe is not None:
830+
lf = lf.filter(fe)
831+
for rexpr in residual_exprs.get(r_alias, []):
832+
lf = lf.filter(rexpr)
833+
return lf
834+
835+
shared_lf = _alias_nodes_lf([first_start_fd, second_start_fd], shared_alias)
836+
second_leaf_lf = _alias_nodes_lf([second_end_fd], second_end_alias)
837+
shared_ids_lf = shared_lf.select(node_col).unique()
838+
first_leaf_ids_lf = _alias_nodes_lf([first_end_fd], first_end_alias).select(node_col).unique()
839+
second_leaf_ids_lf = second_leaf_lf.select(node_col).unique()
840+
fe1 = filter_expr_by_dict_polars(edges, first_edge_match)
841+
fe2 = filter_expr_by_dict_polars(edges, second_edge_match)
842+
first_edges_lf = lf_edges.filter(fe1) if fe1 is not None else lf_edges
843+
second_edges_lf = lf_edges.filter(fe2) if fe2 is not None else lf_edges
844+
left_counts_lf = (
845+
first_edges_lf
846+
.join(shared_ids_lf, left_on=src_col, right_on=node_col, how="semi")
847+
.join(first_leaf_ids_lf, left_on=dst_col, right_on=node_col, how="semi")
848+
.group_by(src_col)
849+
.len("__left_count__")
850+
.rename({src_col: shared_alias})
851+
)
852+
right_base_lf = (
853+
second_edges_lf
854+
.join(shared_ids_lf, left_on=src_col, right_on=node_col, how="semi")
855+
.join(second_leaf_ids_lf, left_on=dst_col, right_on=node_col, how="semi")
856+
)
857+
if group_prop_refs:
858+
fused_lookup_key = "__gfql_fast_second_leaf_id__"
859+
lookup_lf = second_leaf_lf.select(
860+
[pl.col(node_col).alias(fused_lookup_key)]
861+
+ [pl.col(prop).alias(out_col) for out_col, prop in group_prop_refs]
862+
).unique(subset=[fused_lookup_key])
863+
right_base_lf = right_base_lf.join(lookup_lf, left_on=dst_col, right_on=fused_lookup_key, how="inner")
864+
right_rows_lf = right_base_lf.select(
865+
[pl.col(src_col).alias(shared_alias)] + [pl.col(key) for key in output_group_keys]
866+
)
867+
joined_lf = right_rows_lf.join(left_counts_lf, on=shared_alias, how="inner")
868+
# HOT PATH: one collect. left_counts is collected ONLY on the empty-match
869+
# boundary below (collect_all of both plans measured +2.5ms/query on the
870+
# 20k graphbench q5-q7 -- CSE does not absorb the left-arm recompute).
871+
joined = joined_lf.collect()
872+
if len(joined) == 0:
873+
# Eager-lane parity on the empty match: the eager all-left-counts==1
874+
# shortcut counts matched rows with pl.len(), emitting a single n=0 row
875+
# when the first arm is live but nothing joins (the openCypher-correct
876+
# count over zero rows). Every other empty shape returns the 0x0 frame,
877+
# exactly like the eager generic branch.
878+
left_counts_df = left_counts_lf.collect()
879+
if (
880+
not output_group_keys
881+
and len(left_counts_df) > 0
882+
and bool(left_counts_df.select((pl.col("__left_count__") == 1).all()).item())
883+
):
884+
out_df = pl.DataFrame({agg_alias: [0]}).with_columns(pl.col(agg_alias).cast(pl.Int64))
885+
else:
886+
return cast(DataFrameT, joined.select([]))
887+
elif output_group_keys:
888+
out_df = joined.group_by(output_group_keys, maintain_order=True).agg(
889+
pl.col("__left_count__").sum().cast(pl.Int64).alias(agg_alias))
890+
else:
891+
out_df = joined.select(pl.col("__left_count__").sum().cast(pl.Int64).alias(agg_alias))
892+
if order_keys:
893+
out_df = out_df.sort(
894+
[key for key, _ in order_keys],
895+
descending=[desc for _, desc in order_keys],
896+
nulls_last=[not desc for _, desc in order_keys],
897+
)
898+
if limit_value is not None:
899+
out_df = out_df.head(limit_value)
900+
if select_items is not None:
901+
out_df = out_df.select([pl.col(s_col).alias(d_col) for s_col, d_col in select_items])
902+
return cast(DataFrameT, out_df)
903+
904+
770905
def _connected_join_two_star_fast_grouped_count(
771906
base_graph: Plottable,
772907
plan: ConnectedMatchJoinPlan,
@@ -969,6 +1104,37 @@ def _connected_join_two_star_fast_grouped_count(
9691104
if shared_ids is None or first_leaf_ids is None or second_leaf_ids is None:
9701105
return None
9711106
else:
1107+
# FUSED lazy lane (#1755 lane-1): one lazy plan, one collect at the join.
1108+
# Returns None to fall through to the eager path (untranslatable residual,
1109+
# missing group property). Edges are engine-converted HERE because the
1110+
# fused lane runs native polars ops directly on them (the eager path's
1111+
# cached edge filter does its own conversion) -- pandas edges with
1112+
# engine='polars' (incl. WITH..MATCH reentry frames) crash otherwise.
1113+
fused_out = _connected_join_two_star_fused_polars(
1114+
nodes,
1115+
df_to_engine(edges, engine),
1116+
node_col=node_col,
1117+
src_col=src_col,
1118+
dst_col=dst_col,
1119+
residual_map=residual_map,
1120+
shared_alias=shared_alias,
1121+
first_end_alias=first_end_alias,
1122+
second_end_alias=second_end_alias,
1123+
first_start_fd=cast(Optional[dict], first_start.filter_dict),
1124+
second_start_fd=cast(Optional[dict], second_start.filter_dict),
1125+
first_end_fd=cast(Optional[dict], first_end.filter_dict),
1126+
second_end_fd=cast(Optional[dict], second_end.filter_dict),
1127+
first_edge_match=cast(Optional[dict], first_edge.edge_match),
1128+
second_edge_match=cast(Optional[dict], second_edge.edge_match),
1129+
group_prop_refs=group_prop_refs,
1130+
output_group_keys=output_group_keys,
1131+
agg_alias=agg_alias,
1132+
order_keys=order_keys,
1133+
limit_value=limit_value,
1134+
select_items=select_items,
1135+
)
1136+
if fused_out is not None:
1137+
return fused_out
9721138
shared_nodes = filter_by_dict_polars(nodes, cast(Optional[dict], first_start.filter_dict))
9731139
shared_nodes = filter_by_dict_polars(shared_nodes, cast(Optional[dict], second_start.filter_dict))
9741140
first_leaf_nodes = filter_by_dict_polars(nodes, cast(Optional[dict], first_end.filter_dict))

0 commit comments

Comments
 (0)