Skip to content

Commit f3cc959

Browse files
authored
Merge pull request #1731 from graphistry/perf/gfql-olap-split-4-aggregate
feat(gfql): add aggregate fast paths and final hardening
2 parents b405687 + 4803ada commit f3cc959

21 files changed

Lines changed: 2601 additions & 1414 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1193,7 +1193,7 @@ jobs:
11931193
cat build/gfql-coverage-audit/gfql-coverage-audit.md >> "$GITHUB_STEP_SUMMARY"
11941194
11951195
- name: Upload GFQL coverage audit
1196-
if: ${{ matrix.python-version == '3.12' }}
1196+
if: ${{ always() && matrix.python-version == '3.12' }}
11971197
uses: actions/upload-artifact@v4
11981198
with:
11991199
name: gfql-coverage-audit-py3.12

CHANGELOG.md

Lines changed: 2 additions & 3 deletions
Large diffs are not rendered by default.

bin/coverage_audit.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,8 +614,23 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
614614
print(f"Wrote {json_path}")
615615
if exit_code:
616616
print(f"pytest failed with exit code {exit_code}", file=sys.stderr)
617-
if any(check.status == "fail" for check in baseline_checks):
617+
failing_baseline_checks = [check for check in baseline_checks if check.status == "fail"]
618+
if failing_baseline_checks:
618619
print("per-file coverage baseline failed", file=sys.stderr)
620+
for check in failing_baseline_checks:
621+
actual = "missing" if check.actual_percent is None else f"{check.actual_percent:.2f}%"
622+
delta = "n/a" if check.delta_percent is None else f"{check.delta_percent:+.2f}%"
623+
print(
624+
" {path}: actual={actual} floor={floor:.2f}% tolerance={tolerance:.2f}% delta={delta} reason={reason}".format(
625+
path=check.path,
626+
actual=actual,
627+
floor=check.min_percent,
628+
tolerance=check.tolerance_percent,
629+
delta=delta,
630+
reason=check.reason,
631+
),
632+
file=sys.stderr,
633+
)
619634
return exit_code or 3
620635
return exit_code
621636

bin/test-polars.sh

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ set -ex
33

44
# Run from project root
55
# - Extra args are passed through to the pytest phase
6-
# - Set POLARS_COV=1 to collect coverage over the graphistry package; the coverage
6+
# - Set POLARS_COV=1 to collect coverage over graphistry/compute; the coverage
77
# data file location is taken from $COVERAGE_FILE (as the CI py3.12 lane sets it)
88
# - Non-zero exit code on fail
99

@@ -17,25 +17,18 @@ POLARS_TEST_FILES=(
1717
graphistry/tests/compute/gfql/test_engine_polars_hop.py
1818
graphistry/tests/compute/gfql/test_engine_polars_chain.py
1919
graphistry/tests/compute/gfql/test_engine_polars_row_pipeline.py
20+
graphistry/tests/compute/gfql/test_engine_polars_binding_rows.py
2021
graphistry/tests/compute/gfql/test_engine_polars_cypher_conformance.py
2122
graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py
2223
graphistry/tests/compute/gfql/test_conformance_ledger.py
2324
# index tests exercise the seeded-index hook in the polars hop entry (hop.py) — without
2425
# them the hook dominates the now-thin file and trips its per-file coverage floor
2526
graphistry/tests/compute/gfql/index/test_index.py
26-
# Engine coercion tests: the polars paths (df_to_engine, _pl_nan_to_null identity) only
27-
# run where polars is installed, and this lane is the only coverage-collecting lane
28-
# with polars — the core py3.14 lane has no polars extra, so those tests skip there
29-
graphistry/tests/compute/test_engine_coercion.py
3027
)
3128

32-
# The whole graphistry package is measured here (not just graphistry/compute) because
33-
# polars-only branches outside compute/ (e.g. Engine._pl_nan_to_null) are unreachable in
34-
# the core coverage lane (no polars extra); coverage sources must be dirs/packages, and a
35-
# dotted module source (graphistry.Engine) breaks numpy under pytest
3629
COV_ARGS=()
3730
if [ -n "${POLARS_COV:-}" ]; then
38-
COV_ARGS=(--cov=graphistry --cov-report=)
31+
COV_ARGS=(--cov=graphistry/compute --cov-report=)
3932
fi
4033

4134
python -B -m pytest -vv "${COV_ARGS[@]}" "${POLARS_TEST_FILES[@]}" "$@"
@@ -44,7 +37,7 @@ python -B -m pytest -vv "${COV_ARGS[@]}" "${POLARS_TEST_FILES[@]}" "$@"
4437
# appended into the same coverage data file when POLARS_COV=1 (CI audit reads it)
4538
COV_APPEND_ARGS=()
4639
if [ -n "${POLARS_COV:-}" ]; then
47-
COV_APPEND_ARGS=(--cov=graphistry --cov-report= --cov-append)
40+
COV_APPEND_ARGS=(--cov=graphistry/compute --cov-report= --cov-append)
4841
fi
4942
python -B -m pytest -vv "${COV_APPEND_ARGS[@]}" \
5043
graphistry/tests/compute/gfql/cypher/test_lowering.py -k polars

graphistry/Engine.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -214,25 +214,11 @@ def _pl_nan_to_null(df):
214214
polars / Arrow / cuDF input carrying genuine NaN is treated as MISSING like the pandas
215215
oracle (which skipna/dropna's NaN). Without this, ``engine='polars'`` on a frame with a
216216
real NaN keeps rows a filter/aggregation should drop (silent divergence from pandas).
217-
No-op when there are no float columns.
218-
219-
Identity-stable: returns the *same* frame object when no float column actually carries a
220-
NaN. ``fill_nan(None)`` on a NaN-free column is a value-level no-op but still yields a
221-
FRESH frame; that identity churn defeats frame-identity caches keyed on ``source_ref is
222-
df`` (the #1658 CSR index) -- see #1726. So we probe for real NaN presence per float
223-
column (``is_nan`` only applies to float dtypes, hence the schema gate) and rebuild only
224-
when -- and only the columns where -- a NaN is genuinely present. Values are identical to
225-
the old unconditional rewrite (``fill_nan(None)`` never touches non-NaN entries)."""
217+
No-op when there are no float columns."""
226218
import polars as pl
227219
float_cols = [c for c, dt in df.schema.items() if dt in (pl.Float32, pl.Float64)]
228220
if not float_cols:
229221
return df
230-
if isinstance(df, pl.DataFrame):
231-
nan_cols = [c for c in float_cols if df.get_column(c).is_nan().any()]
232-
if not nan_cols:
233-
return df
234-
return df.with_columns([pl.col(c).fill_nan(None) for c in nan_cols])
235-
# LazyFrame (rare): no cheap eager NaN probe, keep the original unconditional rewrite.
236222
return df.with_columns([pl.col(c).fill_nan(None) for c in float_cols])
237223

238224

graphistry/compute/gfql/index/__init__.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@
2323
is_index_op, is_index_op_json,
2424
)
2525
from .cypher_ddl import parse_index_ddl, looks_like_index_ddl
26-
from .cost import (
27-
cost_gate_frac, cost_gate_min_frontier, reset_cost_gate_frac, set_cost_gate_frac,
28-
)
26+
from .cost import cost_gate_frac, reset_cost_gate_frac, set_cost_gate_frac
2927
from .explain import GfqlExplainReport
3028

3129
__all__ = [
@@ -40,7 +38,6 @@
4038
"CreateIndex", "DropIndex", "ShowIndexes", "IndexOp", "apply_index_op",
4139
"index_op_from_json", "is_index_op", "is_index_op_json",
4240
"parse_index_ddl", "looks_like_index_ddl",
43-
"cost_gate_frac", "cost_gate_min_frontier", "reset_cost_gate_frac",
44-
"set_cost_gate_frac",
41+
"cost_gate_frac", "reset_cost_gate_frac", "set_cost_gate_frac",
4542
"GfqlExplainReport",
4643
]

graphistry/compute/gfql/index/api.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
)
2222
from .build import build_adjacency_index, build_node_id_index
2323
from .traverse import index_seeded_hop
24-
from .cost import cost_gate_frac, cost_gate_min_frontier, seed_deg_sum, seed_id_array
24+
from .cost import cost_gate_frac, seed_deg_sum, seed_id_array
2525
from .policy import IndexPolicy, validate_index_policy
2626
from .types import (
2727
AdjacencyIndexKind, EdgeIndexDirection, HopDirection, IndexKind,
@@ -424,15 +424,10 @@ def _bail(reason: str) -> Optional[Plottable]:
424424

425425
# Cost gate: if the frontier covers a large fraction of distinct sources, the
426426
# scan path is competitive — fall back (avoids index overhead on bulk-ish hops).
427-
# Small-frontier floor: a frontier of <= cost_gate_min_frontier() seeds always
428-
# indexes — the frac gate scales with n_keys, so on small/low-cardinality slices
429-
# it would otherwise send even a single-seed hop to the O(E) scan. Pure routing
430-
# heuristic either way: index and scan return identical results.
431427
idx0 = cast(Optional[AdjacencyIndex], registry.get_valid(
432428
EDGE_OUT_ADJ if direction != "reverse" else EDGE_IN_ADJ, g._edges, (src, dst), engine
433429
))
434430
frac = cost_gate_frac(engine)
435-
min_frontier = cost_gate_min_frontier()
436431
if trace and idx0 is not None:
437432
# Free fanout estimate (Σ seed degree) from the CSR offsets — the planner
438433
# signal the report wants EXPLAIN to surface (not just used-index yes/no).
@@ -442,19 +437,16 @@ def _bail(reason: str) -> Optional[Plottable]:
442437
diag["seed_deg_sum"] = deg_sum
443438
diag["est_result_rows"] = deg_sum
444439
diag["threshold_frac"] = frac
445-
diag["min_frontier_floor"] = min_frontier
446440
if idx0 is None:
447441
# required direction not resident (undirected needs both); let driver decide
448442
pass
449443
elif resolved_policy != "force":
450444
try:
451445
frontier_n = int(nodes.shape[0])
452-
if (frontier_n > min_frontier
453-
and idx0.n_keys > 0 and frontier_n >= frac * idx0.n_keys):
446+
if idx0.n_keys > 0 and frontier_n >= frac * idx0.n_keys:
454447
return _bail(
455448
f"frontier {frontier_n} >= {frac}*n_keys "
456-
f"({frac * idx0.n_keys:.0f}) and > floor ({min_frontier}) "
457-
f"-> scan cheaper"
449+
f"({frac * idx0.n_keys:.0f}) -> scan cheaper"
458450
)
459451
except (AttributeError, TypeError, ValueError):
460452
pass

graphistry/compute/gfql/index/cost.py

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,46 +17,6 @@
1717
_COST_GATE_FRAC_DEFAULT = 0.02
1818
_COST_GATE_FRAC_OVERRIDES: Dict[Engine, float] = {}
1919

20-
# Absolute small-frontier floor: at or below this many seed rows the planner NEVER
21-
# bails to scan on the frac gate. The frac gate scales with distinct-key cardinality,
22-
# so on small / low-cardinality edge slices (e.g. per-edge-type homogeneous frames:
23-
# n_keys <= 1/0.02 = 50 at the polars/cudf frac) even a single-node seed trips it and
24-
# the hop scans O(E) despite a resident O(degree) index. A frontier of <= K seeds
25-
# bounds CSR lookup work to K searchsorted probes plus the matching-row gather,
26-
# avoiding that fractional-gate artifact. The actual index/scan crossover remains
27-
# engine- and data-dependent: K is a conservative, environment-tunable default,
28-
# not a measured universal threshold. Constant (not a function of n_keys) on
29-
# purpose: scaling with n_keys is the frac gate's job; the floor bounds absolute
30-
# per-hop probe work where frac*n_keys collapses below a handful of seeds. Uniform
31-
# across engines (pandas's 0.5 frac only overlaps the floor when n_keys <= 2*K).
32-
# Purely a routing heuristic: index and scan return identical results.
33-
_COST_GATE_MIN_FRONTIER_DEFAULT = 16
34-
35-
36-
def cost_gate_min_frontier() -> int:
37-
"""Return the absolute small-frontier floor for the index-vs-scan cost gate.
38-
39-
Frontiers of at most this many seeds always take a resident index, regardless
40-
of the frac gate. ``0`` disables the floor (pure frac gating). Env-overridable
41-
via ``GFQL_INDEX_COST_GATE_MIN_FRONTIER`` for benchmark/diagnostic tuning.
42-
"""
43-
raw = os.environ.get("GFQL_INDEX_COST_GATE_MIN_FRONTIER")
44-
if raw is None or raw == "":
45-
return _COST_GATE_MIN_FRONTIER_DEFAULT
46-
try:
47-
val = int(raw)
48-
except ValueError as ex:
49-
raise ValueError(
50-
f"Invalid GFQL_INDEX_COST_GATE_MIN_FRONTIER={raw!r}: "
51-
f"expected a non-negative integer"
52-
) from ex
53-
if val < 0:
54-
raise ValueError(
55-
f"Invalid GFQL_INDEX_COST_GATE_MIN_FRONTIER={raw!r}: "
56-
f"expected a non-negative integer"
57-
)
58-
return val
59-
6020

6121
def _validate_cost_gate_frac(frac: float) -> float:
6222
if not 0.0 < frac <= 1.0:

graphistry/compute/gfql/index/types.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ class IndexTraceStep(TypedDict, total=False):
3030
seed_deg_sum: Optional[int]
3131
est_result_rows: Optional[int]
3232
threshold_frac: float
33-
min_frontier_floor: int
3433

3534

3635
IndexTrace = List[IndexTraceStep]

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

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -361,32 +361,28 @@ def _try_native_row_op(g_cur, op):
361361
from .search import search_any_polars
362362

363363
fn = getattr(op, "function", None)
364-
if (
365-
fn == "rows"
366-
and op.params.get("binding_ops") is not None
367-
and op.params.get("alias_endpoints") is None
368-
):
369-
# Multi-alias bindings table (#1709): native for fixed-length connected
370-
# patterns. A decline must fall through to the pre-existing correlated
371-
# pattern handler below (EXISTS/searchAny); returning None here would turn
372-
# those already-native shapes into an NIE.
373-
bindings_result = binding_rows_polars(
374-
g_cur, op.params["binding_ops"], op.params.get("attach_prop_aliases")
375-
)
376-
if bindings_result is not None:
377-
return bindings_result
364+
if fn == "rows" and op.params.get("binding_ops") is not None:
365+
# #1731: single-entity boundary rows (MATCH (n) / EXISTS seeds) are handled by
366+
# the pattern-apply helper; try that narrow shape first.
367+
if op.params.get("source") is None:
368+
out = rows_binding_ops_polars(g_cur, op.params["binding_ops"])
369+
if out is not None:
370+
return out
371+
# #1730 gate: only take the multi-alias bindings table when alias_endpoints is
372+
# absent (the alias-endpoints shape is handled elsewhere and must fall through).
373+
if op.params.get("alias_endpoints") is None:
374+
# Multi-alias bindings table (#1709): native for fixed-length connected
375+
# patterns. A decline must fall through to the pre-existing correlated
376+
# pattern handler below (EXISTS/searchAny); returning None here would turn
377+
# those already-native shapes into an NIE.
378+
bindings_result = binding_rows_polars(
379+
g_cur, op.params["binding_ops"], op.params.get("attach_prop_aliases")
380+
)
381+
if bindings_result is not None:
382+
return bindings_result
378383
if _call_native_on_polars(op):
379384
# frame ops (rows/limit/skip/distinct/drop_cols) — engine-polymorphic
380385
return op.execute(g=g_cur, prev_node_wavefront=None, target_wave_front=None, engine=Engine.POLARS)
381-
# correlated pattern-existence family (EXISTS { } / pattern predicates): native
382-
# via chain_polars-computed key sets; unsupported shapes return None -> honest NIE.
383-
if (
384-
fn == "rows"
385-
and op.params.get("binding_ops") is not None
386-
and op.params.get("alias_endpoints") is None
387-
and op.params.get("source") is None
388-
):
389-
return rows_binding_ops_polars(g_cur, op.params["binding_ops"])
390386
if fn == "semi_apply_mark":
391387
# required params are safelist-validated — direct indexing (an or-default
392388
# here could only mask an unvalidated call); neq is the optional one.

0 commit comments

Comments
 (0)