Skip to content

perf(gfql): widen the native polars residual translator to the row evaluator's own lowering - #1832

Merged
lmeyerov merged 4 commits into
masterfrom
gfql/1806-residual-translator-widen
Jul 29, 2026
Merged

perf(gfql): widen the native polars residual translator to the row evaluator's own lowering#1832
lmeyerov merged 4 commits into
masterfrom
gfql/1806-residual-translator-widen

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

What

graphistry/compute/gfql_fast_paths.py::_residual_polars_expr translates a connected-join residual predicate string into a native polars expression. It matched two hand-written regexes and returned None for anything else — and one None drops the entire fused single-collect connected-join plan, re-evaluating that alias's residual through a where_rows chain.

This PR stops re-deriving the semantics and delegates to the row evaluator's own lowering. The translator now parses the residual with the row-expression parser, rewrites alias.col to the bare column its own frame carries, and lowers it with lower_expr via a new seam row_pipeline.lower_single_alias_predicate.

Coverage: before / after

Shapes are listed as they actually arrive at the translator (the renderer normalizes <> to !=, and the lowering constant-folds = toLower('LIT') to = 'lit' before this point).

rendered residual before after
(tolower(a.c) = 'lit') and lower/toupper/upper ✅ regex
(a.c = / >= / <= / > / < literal) ✅ regex
(a.c != 'lit') / (a.c <> 'lit') ❌ decline
(a.c IS NULL) ❌ decline
(a.c IS NOT NULL) ❌ decline
(a.c IN ['x', 'y']) ❌ decline
((x) OR (y)) ❌ decline
(NOT (x)) ❌ decline
(CASE WHEN … THEN … ELSE … END = v) ❌ decline
arithmetic ((a.c + 1 = 26), %, /) ❌ decline
function whitelist (substring, size, coalesce, toInteger, …) ❌ decline
escaped literal ('it\u0027s', 'C:\u005Cx') ❌ decline (raw-text guard)
= / != / IS NULL / IN on a Categorical column ❌ decline (dtype gate)
layout variants: no outer parens, reversed operands, fn on the column side ❌ decline
STARTS WITH / ENDS WITH / CONTAINS / =~ ❌ decline decline — unreachable, see below

Parity argument

Parity is by construction, not by re-derivation. The fallback this lane replaces is, on a polars frame, where_rows_polarslower_expr_strtable.filter(expr). lower_single_alias_predicate calls the same parser and the same lower_expr under the same _SCHEMA dtypes; the only difference is the column naming, and alias.col ↔ col is a bijection over the single frame involved (the fallback renames that very frame to build its prefixed row table). So the fast lane builds the expression the fallback would build and applies it the way the fallback would apply it.

Consequences, each of which is asserted by a test rather than asserted in prose:

  • Accept set == fallback's accept set. Every shape in the table above is pinned by a differential: translate + frame.filter(...), then force _residual_polars_expr to decline and run the real where_rows chain on the same frame, and require frame equality. The expected row ids are pinned too, so a differential both sides get wrong cannot pass as parity.
  • Decline set == fallback's decline set. Every declined shape is one where the chain fallback raises its designed NotImplementedError — pinned by TestWrongDtypeStaysResidual and TestStringPredicatesAreUnreachable. Nothing is declined that the evaluator would have answered, except the bare identity sentinel (below), and nothing is accepted that the evaluator would have refused.
  • Guards are inherited, not restated. The numeric-vs-string cross-type decline, the ISO-temporal declines, the int-literal-division decline and the float NaN guard (polars ranks NaN largest; IEEE/pandas/Cypher do not) now come from lower_expr itself, so the fast lane cannot drift from the evaluator when those change. The previous regex lane restated a subset of them by hand and had no NaN guard at all.
  • 3-valued NULL, the sharp edge. (a.name != 'BOB') with a NULL name yields NULL, and filter DROPS the row — the same as =. A pandas-shaped object-dtype != would have kept it. This is pinned per shape (TestWidenedShapesTakeTheNativePath) and end to end through gfql().
  • NaN never reaches this filter through gfql() — probe-verified on both the eager apply lane and the fused lane: gfql ingest normalizes NaN → null (_pl_nan_to_null) before the residual translator sees the frame. The inherited NaN guard is belt-and-braces on top of that.
  • Cross-engine. test_widened_residual_matches_pandas_oracle runs each widened predicate end to end and compares the polars fused-lane answer to the pandas engine's answer.

Escaped literals stop being a special case for a concrete reason: the literal is now unescaped by the evaluator's own parser instead of compared as raw regex-captured text, so there is nothing left to mismatch. The replacement is a non-vacuous differential over rows that really contain ' and \.

Every decline and its reason

declined reason
STARTS WITH / ENDS WITH / CONTAINS / =~ Unreachable. _pushdown_connected_join_where_filters cannot render these to a row filter, so on the polars engine the whole comma-pattern query is rejected upstream with GFQLValidationError and no such residual ever reaches the translator. Pinned by a test that spies the translator and asserts it is never called. Covering them here would be dead code. (Separately: lower_expr has no native kernel for them either, so the fallback also raises.)
property access on another alias ((b.age = 25), (a.age + b.age = 2), foreign alias inside NOT/IS NULL/IN/CASE) That alias's columns are not in this frame, and the fallback's prefixed row table cannot resolve them either → its designed NotImplementedError.
bare identifier ((age = 25)) No bare name is a column of the prefixed row table, so the fallback declines it; accepting it here would invent a resolution.
bare whole-entity identity sentinel ((__gfql_node_id__ = 1), (a.__gfql_node_id__ = 1)) The fallback does resolve this one, via the row table's _NODE_ID. This frame publishes no identity column, so we decline. Costs only speed — the caller falls back and the row-table route answers it — and cannot change an answer. Documented in both docstrings.
absent column ((a.missing = 25)) Designed NotImplementedError from the fallback.
numeric literal vs String column and the converse ((a.name != 25), (a.age != 'thirty')), cross-category IN ((a.age IN ['x'])) lower_expr's _is_cross_type / _lower_in guard: polars raises where the evaluator has a designed error. Must stay residual so the designed error is what surfaces, not a raw polars ComputeError.
toLower/toUpper on a non-String column, incl. Categorical .str on Categorical raises in polars only; the row lowering gates the case functions on a String output dtype.
node types outside the row lowering's whitelist (map / subscript / slice / quantifier / comprehension) lower_expr declines them anyway.
unparseable text Parse failure → decline, never a half-translated filter. Also covered: no parser bundle available.

Behaviour change worth calling out

TestFusedTwoStarLane::test_non_ascii_two_sided_declines_the_lane_but_not_the_answer asserted the fused lane declines a two-sided toLower(x) = toLower('FINE DINİNG') residual. That decline was an artifact of the regexes being unable to match two-sided text, and it bought nothing: the where_rows fallback it deferred to lowers both sides with the same polars to_lowercase kernel, so the answer was already the Rust-cased one. The test is rewritten to assert the invariant that actually protects the Python-vs-Rust case table — the plan-time constant fold still declines, so the residual text arrives unfolded — plus the answer-equals-fallback assertion, which is unchanged.

Tests

graphistry/tests/compute/gfql/test_residual_polars_native.py (in bin/test-polars.sh): 127 passed. New: TestWidenedShapesTakeTheNativePath, TestEscapedLiteralParity, TestLiteralTextSemanticsParity (nulls, empty string, non-ASCII, ./*/[ metacharacters treated as data, 3-valued booleans), TestWrongDtypeStaysResidual, TestCategoricalNonStrOpsParity, TestStringPredicatesAreUnreachable, plus end-to-end fused-lane engagement (served == 1) and pandas-oracle cases for eight widened predicates.

Run on the DGX box (RAPIDS 26.02 image, polars 1.35.2 / pandas 2.3.3, --gpus all):

lane result
bin/test-polars.sh phase 1 5131 passed, 31 skipped, 4 xfailed, 1 failed
bin/test-polars.sh phase 2 (test_lowering.py -k polars) 84 passed
gfql-core file list (from .github/workflows/ci.yml) 9196 passed, 63 skipped, 19 xfailed, 7 failed

All 8 failures reproduce identically on the pristine base tree (68638794c) in the same container and are environment artifacts, not regressions: test_chain_dask_edges (dask/pandas version in the image) and 7 cudf-flavoured cases (test_engine_coerces_cudf_to_pandas, four tests/gfql/ref/test_chain_optimizations.py [cudf] params, two test_df_executor_core.py without-cudf cases) that CI's pandas-only gfql-core lane never runs.

Lint / typecheck

./bin/ruff.sh clean; ./bin/typecheck.shSuccess: no issues found in 327 source files.

bin/ci_type_hygiene_guard.py reports byte-identical findings on this branch and on the base tree — the diff of its per-file report is empty, so this change adds zero findings. Note for the reviewer: the guard is already failing on master 68638794c (python-lint-types 3.11/3.13/3.14 are red on that commit) for gfql_fast_paths.py (138 vs cap 133), row_pipeline.py (19 vs 18) and gfql_unified.py (43 vs 42) — a pre-existing baseline drift this PR neither causes nor fixes.

No benchmark claim

No performance number is claimed and none was measured. Whether this moves any graph-benchmark q1–q9 cell is unknown, and the queries on that board may not use these operators at all. The justification is the predicate vocabulary the Cypher front end already accepts, not a benchmark cell.

Observed, out of scope

  • Engine asymmetry, pre-existing: a comma-pattern MATCH … WHERE x STARTS WITH 'a' answers on engine='pandas' and is rejected on engine='polars' with GFQLValidationError. The fix is in _pushdown_connected_join_where_filters (plus native string-predicate kernels in lower_expr), not in the residual translator.
  • pandas-side defect, pre-existing: an x IN [...] residual in the same comma-pattern shape raises GFQLTypeError: Unalignable boolean Series provided on engine='pandas'. The polars fast lane and the polars where_rows fallback agree on it (pinned); the pandas oracle test excludes IN for this reason, with the reason written in the test.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx

…aluator's own lowering

_residual_polars_expr matched two hand-written regex shapes and declined everything
else, and one decline drops the whole fused connected-join plan. It now parses the
residual with the row-expression parser, rewrites alias.col to the bare column its
frame carries, and lowers it with the SAME lower_expr the where_rows fallback uses
(row_pipeline.lower_single_alias_predicate) -- so parity is by construction: same
parser, same lowering, same _SCHEMA dtypes, same table.filter.

Newly covered: != / <>, IS NULL, IS NOT NULL, IN [literals], OR, NOT, CASE WHEN,
arithmetic, the function whitelist, and escaped/Categorical operands. Every decline
is a shape the fallback declines too, so the designed NotImplementedError still
surfaces. STARTS WITH / ENDS WITH / CONTAINS / =~ are NOT covered: the connected-join
WHERE renderer cannot emit them, so no such residual can reach the translator.

No benchmark claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
lmeyerov and others added 3 commits July 28, 2026 20:50
…ead NaN mask

#1832 widened `_residual_polars_expr` to the row evaluator's own lowering. The
coverage win is real, but it regressed the matched graph-benchmark lane at both
scales -- q7 1.24x/1.16x with disjoint per-slot ranges, q5 1.13x/1.09x also
disjoint, q6 slower within overlap -- on cells whose residuals were ALREADY
native, so the board paid the cost with none of the benefit.

Two measured mechanisms, two scoped fixes:

1. Parse tax, 3.3 -> 162 us/call. `lower_single_alias_predicate` re-parsed and
   re-lowered on every call; most of the cost is the per-operand schema-width
   LazyFrame probe in `_expr_output_dtype`, not the parse. Now memoized behind a
   bounded LRU (512) at 2.3 us/call, keyed on (expr, alias, columns_nan_free,
   ((column, dtype-repr), ...)) -- names AND dtypes AND order, with `str(dtype)`
   rather than the dtype object because polars DataType.__eq__ equates a class
   with its parameterized instances. Parser availability is process state, not an
   argument, so it is probed ahead of the cache instead of keyed into it.

2. A costlier expression, which is why the loss grew with scale. The general
   lowering masks every float comparison with `& col.is_nan().not()`; that took
   `p.age >= 23` from 0.23 to 0.52 ms at 100k. On the fused lane the mask is dead
   for a BARE COLUMN read -- those frames are projections of `_nodes`, already
   run through `_pl_nan_to_null` by `_coerce_input_formats`. New
   `lowering_context.COLUMNS_NAN_FREE` contextvar, opted into by an explicit
   `columns_nan_free=True` that only this lane passes. Default is guard-ON, so
   the general row-table lowering is untouched and future callers are safe by
   default. Computed operands (0.0/0.0, sqrt of a negative) keep the mask on both
   lanes -- in-query math manufactures NaN no ingest can have removed. This
   restores exactly the guard-free set of the pre-widening narrow matcher.

Measured, three arms, engine=polars, RUNS=101 WARMUP=10, 15 position-balanced
slots, dgx-spark, arms differing only by the pygraphistry tree:

  q7  20k  base 5.27  PR 6.54  fix 5.38   PR-vs-base 1.24x LOSS -> fix TIE 1.02x
  q7 100k  base 9.76  PR 11.33 fix 9.95   PR-vs-base 1.16x LOSS -> fix TIE 1.02x
  q5  20k  base 4.60  PR 5.18  fix 4.37   PR-vs-base 1.13x LOSS -> fix TIE 0.95x
  q5 100k  base 13.08 PR 14.32 fix 13.00  PR-vs-base 1.09x LOSS -> fix TIE 0.99x
  q6  20k  base 5.68  PR 6.35  fix 5.57   -> fix TIE 0.98x
  q6 100k  base 14.16 PR 15.14 fix 14.23  -> fix TIE 1.01x

No other cell moved; every cell's VALUE is identical across all three arms at
both scales.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
`_operand_is_nan_free_column` is the single place the suppression is decided, and
its `PropertyAccessExpr` arm is not reachable from the fused lane -- that lane
rewrites predicates to bare columns via `_bare_column_ast`, so only the
`Identifier` arm runs. That arm is what a future caller opting in on a PREFIXED
row table would hit, so it is exercised directly rather than left as an untested
branch a later change could silently break.

Covers: opt-in off (nothing is nan-free, whatever the node), the bare-identifier
arm present/absent, the property-access arm resolving/not-resolving/non-Identifier
base, and computed/literal/None nodes never being nan-free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
@lmeyerov

Copy link
Copy Markdown
Contributor Author

The widening regressed the board; root-caused and fixed in 356a145 (+ test coverage in b0cad46)

The coverage win in this PR is unaffected. But the widening regressed the matched graph-benchmark lane at both scales, on exactly the cells that carry residuals — and those cells' residuals were already native under the narrow matcher, so the board paid the cost with none of the benefit. Both mechanisms were reproduced independently before fixing, and both are fixed.

Mechanism (a) — parse tax, 3.3 → 162 µs/call

lower_single_alias_predicate re-parsed and re-lowered on every call. Measured against the board's real 14-column node schema (not a trimmed one), base ed3904515 3.26 µs/call vs PR 7e9bd662d 162.2 µs/call. Most of that is not the parse — it is the per-operand schema-width LazyFrame probe in _expr_output_dtype, which is why the cost scales with schema width.

Now memoized behind a bounded LRU (512 entries): 2.31 µs/call, below base.

Key is (expr, alias, columns_nan_free, ((column, str(dtype)), ...)). Names and dtypes and their order — names alone would be a stale-key bug, since the same predicate over the same column names lowers differently when a dtype changes. str(dtype) rather than the dtype object, because polars' DataType.__eq__ equates a dtype class with its parameterized instances (Datetime == Datetime('ns')) and as a dict key that could hit across dtypes that lower differently. Parser availability is deliberately not keyed — it is process state, not an argument — so it is probed ahead of the cache (~0.22 µs, measured) and no entry can outlive a parser that has gone away.

Mechanism (b) — a costlier expression, which is why the loss grew with scale

The general lowering emits a NaN guard the narrow path deliberately omitted:

base: [(col("age")) >= (dyn int: 23)]
PR:   [([(col("age")) >= (dyn int: 23)]) & (col("age").is_nan().not())]

Measured at 100k: age >= 23 0.226 → 0.516 ms, age <= 30 0.355 → 0.808 ms.

The premise was re-verified rather than assumed. A natively-built polars frame with 50 genuine NaNs injected into age (bypassing pandas entirely) was pushed through gfql(engine='polars'): the ingested frame reaching the lane carries no NaN (_coerce_input_formats_pl_nan_to_null), and base/PR/pandas-oracle all answer identically. The eager and LazyFrame ingest arms were both checked.

Suppression is opt-in via a new lowering_context.COLUMNS_NAN_FREE contextvar, threaded by an explicit lower_single_alias_predicate(..., columns_nan_free=True) that only _residual_polars_expr passes. Default is guard-ON, so the general row-table lowering is untouched and any future caller is safe without knowing this exists.

It is also scoped to bare column reads, never to computed operands: n.a/n.b is NaN at 0.0/0.0 and sqrt(n.x) is NaN at x<0 even on a perfectly clean column, so in-query float math manufactures NaN that no ingest can have removed. A float literal keeps its own (constant-false) term too, since only column reads are in scope — pinned as a deliberate boundary rather than left implicit. This restores exactly the guard-free set of the pre-#1832 narrow matcher, and mirrors predicates.filter_expr_by_dict_polars, which already omits the mask on this lane for the same reason.

Three-arm measurement

dgx-spark under the perf lock, engine=polars, q1–q9, RUNS=101 WARMUP=10, 15 position-balanced slots (arm position-sums 39/40/41), both scales. Arms differ only by the pygraphistry tree, verified per slot by per-file md5. Medians of per-slot medians, ms. Verdict is TIE when per-slot ranges overlap.

cell base ed3904515 PR 7e9bd662d fixed P vs base fix vs base
q5 20k 4.60 5.18 4.37 1.13× LOSS (disjoint) TIE 0.95×
q5 100k 13.08 14.32 13.00 1.09× LOSS (disjoint) TIE 0.99×
q6 20k 5.68 6.35 5.57 TIE 1.12× TIE 0.98×
q6 100k 14.16 15.14 14.23 TIE 1.07× TIE 1.01×
q7 20k 5.27 6.54 5.38 1.24× LOSS (disjoint) TIE 1.02×
q7 100k 9.76 11.33 9.95 1.16× LOSS (disjoint) TIE 1.02×

q1–q4, q8, q9 are TIE on every pairing. Every cell's VALUE is identical across all three arms at both scales.

q7 recovered to parity. q5 and q6 did too.

One correction to the earlier characterization: q5 is a disjoint LOSS on the PR arm at both scales, not a TIE — a somewhat stronger regression than previously reported. q6 does stay within overlap, as reported.

Verification

  • Full polars lane on dgx: 5155 passed, 31 skipped, 4 xfailed. The one failure (test_chain_dask_edges) is pre-existing — it fails identically on the unfixed PR head, a dask artifact of the test image.
  • test_residual_polars_native.py: 148 passed, including new tests that the fused lane emits a guard-free expression, the general lowering still emits the mask, a computed operand keeps it and is answered IEEE-style over a real in-query 0.0/0.0, a genuine NaN bypassing pandas is normalized by ingest before the lane sees it, and the memo agrees with the uncached lowering on every shape while a dtype change alone yields a different expression.
  • bin/lint.sh type-hygiene guard: no growth (4 files now below baseline). ruff clean. mypy clean, no new Any/cast/bare generics.
  • Structural lock-in in pyg-bench (graphistry/pyg-bench#120, merged): fails on the unfixed PR head 7e9bd662d (5 failed / 84 passed), passes on the fixed tree (89 passed) — so the cost cannot come back silently. The stale "q7 is a non-overlapping regression" disclosure in the demos(aws vpcflow cloudwatch) #119 lock-in is updated to the three-arm result.

The CHANGELOG's No benchmark claim is made line on the original entry has been corrected — the number was measured, and it was negative until this fix.

@lmeyerov
lmeyerov merged commit 99c1497 into master Jul 29, 2026
69 checks passed
@lmeyerov
lmeyerov deleted the gfql/1806-residual-translator-widen branch July 29, 2026 09:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant