Skip to content

Commit 0bb7761

Browse files
authored
Merge pull request #1763 from graphistry/perf/gfql-polars-residual-native
perf(gfql): native polars translation for simple connected-join residuals (#1729/#1755)
2 parents c76af2c + 0662ec2 commit 0bb7761

4 files changed

Lines changed: 332 additions & 6 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 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.
1415
- **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.
1516

1617
### Documentation

bin/test-polars.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ POLARS_TEST_FILES=(
2828
graphistry/tests/compute/gfql/test_optional_match_polars_frames.py
2929
graphistry/tests/compute/gfql/test_polars_rows_entity_groupby.py
3030
graphistry/tests/compute/gfql/test_seeded_typed_hop_fastpath.py
31+
graphistry/tests/compute/gfql/test_residual_polars_native.py
3132
# index tests exercise the seeded-index hook in the polars hop entry (hop.py) — without
3233
# them the hook dominates the now-thin file and trips its per-file coverage floor
3334
graphistry/tests/compute/gfql/index/test_index.py

graphistry/compute/gfql_fast_paths.py

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010

1111
from dataclasses import replace
1212
import pandas as pd
13+
import re
1314
from types import MappingProxyType
14-
from typing import Any, Dict, List, Literal, Mapping, Optional, Sequence, Set, Tuple, Union, cast
15+
from typing import Any, Dict, List, Literal, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING, Union, cast
1516
from graphistry.Plottable import Plottable
17+
18+
if TYPE_CHECKING:
19+
import polars as pl
1620
from graphistry.Engine import Engine, EngineAbstract, POLARS_ENGINES, df_concat, df_cons, df_to_engine, df_unique, resolve_engine
1721
from graphistry.util import setup_logger
1822
from .ast import ASTObject, ASTLet, ASTNode, ASTEdge, ASTCall
@@ -606,6 +610,96 @@ def _connected_join_two_star_split_residuals(
606610
return residuals, rest
607611

608612

613+
# The simple residual shapes the connected-join lowering emits for scalar predicates it
614+
# cannot push into filter_dict (see #1729): case-insensitive equality and scalar
615+
# equality/range on a single aliased column. Anything else falls back to the where_rows
616+
# chain evaluator. Literals: single-quoted strings (no embedded quotes) or numbers.
617+
_RESIDUAL_TOLOWER_EQ = re.compile(
618+
r"^\(tolower\((?P<alias>\w+)\.(?P<col>\w+)\) = tolower\('(?P<lit>[^']*)'\)\)$"
619+
)
620+
_RESIDUAL_SCALAR_CMP = re.compile(
621+
r"^\((?P<alias>\w+)\.(?P<col>\w+) (?P<op>=|>=|<=|>|<) "
622+
r"(?:'(?P<slit>[^']*)'|(?P<nlit>-?\d+(?:\.\d+)?))\)$"
623+
)
624+
625+
626+
def _residual_polars_expr(
627+
expr: str, alias: str, schema: Mapping[str, Any]
628+
) -> Optional['pl.Expr']:
629+
"""Translate a simple residual to a native polars expression, or None to fall back.
630+
631+
``expr`` is a *string* by contract: the #1729 connected-join lowering serializes
632+
residual predicates into ASTCall params as canonical predicate strings (e.g.
633+
``(tolower(a.col) = tolower('lit'))``), not typed AST terms — so string parsing here
634+
is the honest interface; a typed term would require a lowering-level refactor.
635+
636+
Covered (exactly the #1729 scalar-residual shapes): ``(tolower(a.col) = tolower('lit'))``
637+
and ``(a.col <op> literal)`` for ``= >= <= > <``. Semantics match the where_rows
638+
evaluator on these shapes: string compares are null-safe (null -> filtered out, since
639+
polars comparisons on null yield null which ``filter`` drops, same as the evaluator's
640+
null-propagating comparisons); toLower equality lowercases the column via polars
641+
``str.to_lowercase()`` and the literal via Python ``str.lower()`` (empirically equal
642+
on the ASCII/latin shapes the lowering emits; a divergence would need a Rust-vs-Python
643+
Unicode table drift). Float NaN ranking differs between polars and the evaluator, but
644+
gfql ingest normalizes NaN->null (``_pl_nan_to_null``) so NaN never reaches this
645+
filter through ``gfql()``. Declines (returns None, caller uses the chain fallback) on:
646+
any other shape, non-matching alias, a column absent from the schema, an ESCAPED
647+
string literal (``\\`` — the renderer escapes ``' \\ \\n`` etc. to ``\\uXXXX`` which the
648+
evaluator unescapes; raw comparison would silently mismatch), and dtype-incompatible
649+
column/literal pairs (string predicate on non-string column and vice versa — the
650+
lowering deliberately keeps those residual so the evaluator can raise its designed
651+
parity-or-error NotImplementedError rather than a raw polars ComputeError).
652+
"""
653+
import polars as pl
654+
655+
def _is_string_dtype(dtype: Any) -> bool:
656+
return dtype == pl.Utf8 or dtype == pl.String
657+
658+
def _is_numeric_dtype(dtype: Any) -> bool:
659+
return dtype.is_numeric() if hasattr(dtype, "is_numeric") else False
660+
661+
m = _RESIDUAL_TOLOWER_EQ.match(expr)
662+
if m is not None:
663+
col_name = m.group("col")
664+
tolower_lit = m.group("lit")
665+
if m.group("alias") != alias or col_name not in schema:
666+
return None
667+
if "\\" in tolower_lit:
668+
return None # escaped literal: let the evaluator unescape it
669+
if not _is_string_dtype(schema[col_name]):
670+
return None # tolower on non-string column: evaluator raises designed NIE
671+
return pl.col(col_name).str.to_lowercase() == tolower_lit.lower()
672+
m = _RESIDUAL_SCALAR_CMP.match(expr)
673+
if m is not None:
674+
col_name = m.group("col")
675+
if m.group("alias") != alias or col_name not in schema:
676+
return None
677+
lit: Any
678+
if m.group("slit") is not None:
679+
lit = m.group("slit")
680+
if "\\" in lit:
681+
return None # escaped literal: let the evaluator unescape it
682+
if not _is_string_dtype(schema[col_name]):
683+
return None # string literal vs non-string column: designed NIE path
684+
else:
685+
raw = m.group("nlit")
686+
lit = float(raw) if "." in raw else int(raw)
687+
if not _is_numeric_dtype(schema[col_name]):
688+
return None # numeric literal vs non-numeric column: designed NIE path
689+
col = pl.col(col_name)
690+
op = m.group("op")
691+
if op == "=":
692+
return col == lit
693+
if op == ">=":
694+
return col >= lit
695+
if op == "<=":
696+
return col <= lit
697+
if op == ">":
698+
return col > lit
699+
return col < lit
700+
return None
701+
702+
609703
def _connected_join_apply_node_residuals(
610704
base_graph: Plottable,
611705
node_frame: DataFrameT,
@@ -617,14 +711,26 @@ def _connected_join_apply_node_residuals(
617711
) -> DataFrameT:
618712
"""Filter a fast-path node frame by single-alias post-join residual expressions.
619713
620-
Reuses the row pipeline's ``where_rows`` evaluator (identical semantics to the slow path,
621-
so toLower/etc. behave exactly as they would post-join) by aliasing the node columns to
622-
``alias.col`` and dispatching a where_rows chain, then renaming back. ``validate_schema`` is
623-
disabled because the residual references flat ``alias.col`` columns rather than a bound
624-
graph element.
714+
Fast lane (polars): the simple scalar shapes the #1729 lowering emits
715+
(``tolower(a.col) = tolower('lit')``, ``a.col <op> literal``) translate directly to
716+
native polars filters — no chain dispatch (the where_rows chain costs ~1.7ms/alias,
717+
the dominant cost of the residual OLAP fast path). Any expression outside those
718+
shapes falls back to the chain evaluator below, so semantics never diverge.
719+
720+
Fallback: reuses the row pipeline's ``where_rows`` evaluator (identical semantics to
721+
the slow path, so toLower/etc. behave exactly as they would post-join) by aliasing
722+
the node columns to ``alias.col`` and dispatching a where_rows chain, then renaming
723+
back. ``validate_schema`` is disabled because the residual references flat
724+
``alias.col`` columns rather than a bound graph element.
625725
"""
626726
is_polars = "polars" in type(node_frame).__module__
627727
if is_polars:
728+
translated = [_residual_polars_expr(e, alias, dict(node_frame.schema)) for e in exprs]
729+
if all(t is not None for t in translated):
730+
out = node_frame
731+
for t in translated:
732+
out = out.filter(t)
733+
return cast(DataFrameT, out)
628734
aliased = node_frame.rename({col: f"{alias}.{col}" for col in node_frame.columns})
629735
else:
630736
aliased = node_frame.rename(columns={col: f"{alias}.{col}" for col in node_frame.columns})

0 commit comments

Comments
 (0)