Skip to content

Commit 8535553

Browse files
authored
Merge pull request #1800 from graphistry/perf/gfql-fused-tolower-one-sided
perf(gfql): constant-fold literal-only sub-expressions at plan time (generalizes the toLower residual fix)
2 parents b6181d3 + ba6e499 commit 8535553

10 files changed

Lines changed: 1702 additions & 36 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

bin/ci_cypher_surface_guard_baseline.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@
1313
"max_properties": 0
1414
}
1515
},
16-
"lowering_py_max_lines": 9255
16+
"lowering_py_max_lines": 9260
1717
}

bin/test-polars.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ POLARS_TEST_FILES=(
5555
# polars-parametrized cases inside otherwise-pandas modules: these files DO run in the
5656
# pandas lanes, but their polars/polars-gpu parameters are skipped there for want of the
5757
# wheel, so the polars lane is the only place those parameters can execute
58+
graphistry/tests/compute/gfql/test_const_fold_engine_parity.py
5859
graphistry/tests/compute/gfql/index/test_indexed_bindings.py
5960
graphistry/tests/compute/gfql/test_reentry_caller_graph_immutability.py
6061
graphistry/tests/compute/gfql/test_rewrite_param_discard.py

graphistry/compute/gfql/cypher/lowering.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
compile_cypher_call,
131131
)
132132
from graphistry.compute.gfql.cypher.ast_normalizer import ASTNormalizer
133+
from graphistry.compute.gfql.expr_const_fold import fold_constants
133134
from graphistry.compute.gfql.cypher.shortest_path_aliases import (
134135
_ShortestPathAliasSpec,
135136
_is_variable_length_relationship_pattern,
@@ -1795,7 +1796,11 @@ def _row_expr_arg(
17951796
node,
17961797
integer_identifiers=frozenset(),
17971798
)
1798-
return _render_expr_node(rewritten)
1799+
# Plan-time constant folding, so ONE canonical predicate text reaches the row
1800+
# evaluators and the residual translator whichever equivalent spelling was
1801+
# written. Runs AFTER the integer-division rewrite (order is load-bearing) and
1802+
# only where every engine provably agrees — see expr_const_fold, criterion (E).
1803+
return _render_expr_node(fold_constants(rewritten))
17991804

18001805

18011806
def _projected_source_replacement(binding: _StageColumnBinding) -> str:

graphistry/compute/gfql/expr_const_fold.py

Lines changed: 457 additions & 0 deletions
Large diffs are not rendered by default.

graphistry/compute/gfql_fast_paths.py

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
from graphistry.compute.gfql.passes import DEFAULT_LOGICAL_PASSES, DEFAULT_TIER2_PASSES, PassManager
8686
from graphistry.compute.gfql.row.pipeline import _RowPipelineAdapter, is_row_pipeline_call
8787
from graphistry.compute.gfql.search_any import search_any_mask
88-
from graphistry.compute.typing import DataFrameT, SeriesT, NodeDtypes
88+
from graphistry.compute.typing import DataFrameT, DType, SeriesT, NodeDtypes
8989
from graphistry.compute.util.generate_safe_column_name import generate_safe_column_name
9090
from graphistry.compute.validate.validate_schema import validate_chain_schema
9191
from graphistry.compute.gfql_validate import gfql_validate as gfql_preflight_validate
@@ -611,11 +611,20 @@ def _connected_join_two_star_split_residuals(
611611

612612

613613
# 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
614+
# cannot push into filter_dict (see #1729): case-folded equality and scalar
615615
# equality/range on a single aliased column. Anything else falls back to the where_rows
616616
# 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>[^']*)'\)\)$"
617+
#
618+
# ONE CANONICAL SHAPE. The lowering constant-folds literal-only sub-expressions
619+
# (``graphistry/compute/gfql/expr_const_fold.py``), so ``toLower(a.c) = toLower('LIT')``
620+
# arrives here already reduced to ``(tolower(a.c) = 'lit')``. This matcher therefore
621+
# learns the case-function-vs-LITERAL form only, and the literal is used AS WRITTEN --
622+
# the where_rows evaluator does not case-fold a bare literal, so ``toLower(x) = 'MALE'``
623+
# correctly matches nothing. (Case-folding the RHS here would be a silent wrong answer
624+
# that every all-lowercase benchmark literal would hide.)
625+
_RESIDUAL_CASE_FN_EQ = re.compile(
626+
r"^\((?P<fn>tolower|lower|toupper|upper)\((?P<alias>\w+)\.(?P<col>\w+)\) = "
627+
r"'(?P<lit>[^']*)'\)$"
619628
)
620629
_RESIDUAL_SCALAR_CMP = re.compile(
621630
r"^\((?P<alias>\w+)\.(?P<col>\w+) (?P<op>=|>=|<=|>|<) "
@@ -624,25 +633,30 @@ def _connected_join_two_star_split_residuals(
624633

625634

626635
def _residual_polars_expr(
627-
expr: str, alias: str, schema: Mapping[str, Any]
636+
expr: str, alias: str, schema: NodeDtypes
628637
) -> Optional['pl.Expr']:
629638
"""Translate a simple residual to a native polars expression, or None to fall back.
630639
631640
``expr`` is a *string* by contract: the #1729 connected-join lowering serializes
632641
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
642+
``(tolower(a.col) = 'lit')``), not typed AST terms — so string parsing here
634643
is the honest interface; a typed term would require a lowering-level refactor.
635644
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:
645+
Covered (exactly the #1729 scalar-residual shapes): ``(<case fn>(a.col) = 'lit')``
646+
for ``toLower``/``lower``/``toUpper``/``upper``, and ``(a.col <op> literal)`` for
647+
``= >= <= > <``. Semantics match the where_rows evaluator on these shapes: string
648+
compares are null-safe (null -> filtered out, since polars comparisons on null yield
649+
null which ``filter`` drops, same as the evaluator's null-propagating comparisons);
650+
the case functions map to the SAME polars kernels the evaluator uses
651+
(``str.to_lowercase`` / ``str.to_uppercase``) so the column side is identical by
652+
construction, and the LITERAL side is used verbatim because the evaluator does not
653+
case-fold a bare literal. Nothing is case-folded in Python here — the lowering's
654+
constant-folding pass has already reduced ``= toLower('LIT')`` to ``= 'lit'`` where
655+
every engine provably agrees on that value (see ``expr_const_fold`` and #1802), and
656+
where it does not, the two-sided text arrives unfolded and DECLINES below.
657+
Float NaN ranking differs between polars and the evaluator, but gfql ingest
658+
normalizes NaN->null (``_pl_nan_to_null``) so NaN never reaches this filter through
659+
``gfql()``. Declines (returns None, caller uses the chain fallback) on:
646660
any other shape, non-matching alias, a column absent from the schema, an ESCAPED
647661
string literal (``\\`` — the renderer escapes ``' \\ \\n`` etc. to ``\\uXXXX`` which the
648662
evaluator unescapes; raw comparison would silently mismatch), and dtype-incompatible
@@ -652,23 +666,29 @@ def _residual_polars_expr(
652666
"""
653667
import polars as pl
654668

655-
def _is_string_dtype(dtype: Any) -> bool:
669+
def _is_string_dtype(dtype: DType) -> bool:
656670
return dtype == pl.Utf8 or dtype == pl.String
657671

658-
def _is_numeric_dtype(dtype: Any) -> bool:
672+
def _is_numeric_dtype(dtype: DType) -> bool:
659673
return dtype.is_numeric() if hasattr(dtype, "is_numeric") else False
660674

661-
m = _RESIDUAL_TOLOWER_EQ.match(expr)
675+
m = _RESIDUAL_CASE_FN_EQ.match(expr)
662676
if m is not None:
663677
col_name = m.group("col")
664-
tolower_lit = m.group("lit")
678+
case_lit = m.group("lit")
665679
if m.group("alias") != alias or col_name not in schema:
666680
return None
667-
if "\\" in tolower_lit:
681+
if "\\" in case_lit:
668682
return None # escaped literal: let the evaluator unescape it
669683
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()
684+
return None # case fn on non-string column: evaluator raises designed NIE
685+
col_str = pl.col(col_name).str
686+
folded_col = (
687+
col_str.to_lowercase()
688+
if m.group("fn") in ("tolower", "lower")
689+
else col_str.to_uppercase()
690+
)
691+
return folded_col == case_lit
672692
m = _RESIDUAL_SCALAR_CMP.match(expr)
673693
if m is not None:
674694
col_name = m.group("col")
@@ -712,8 +732,9 @@ def _connected_join_apply_node_residuals(
712732
"""Filter a fast-path node frame by single-alias post-join residual expressions.
713733
714734
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,
735+
(``tolower(a.col) = 'lit'`` and the other case functions, ``a.col <op> literal``;
736+
the lowering constant-folds ``= toLower('LIT')`` into that one shape) translate
737+
directly to native polars filters — no chain dispatch (the where_rows chain costs ~1.7ms/alias,
717738
the dominant cost of the residual OLAP fast path). Any expression outside those
718739
shapes falls back to the chain evaluator below, so semantics never diverge.
719740

0 commit comments

Comments
 (0)