perf(gfql): widen the native polars residual translator to the row evaluator's own lowering - #1832
Conversation
…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
…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
The widening regressed the board; root-caused and fixed in
|
| 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.shtype-hygiene guard: no growth (4 files now below baseline).ruffclean.mypyclean, no newAny/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.
What
graphistry/compute/gfql_fast_paths.py::_residual_polars_exprtranslates a connected-join residual predicate string into a native polars expression. It matched two hand-written regexes and returnedNonefor anything else — and oneNonedrops the entire fused single-collect connected-join plan, re-evaluating that alias's residual through awhere_rowschain.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.colto the bare column its own frame carries, and lowers it withlower_exprvia a new seamrow_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).(tolower(a.c) = 'lit')andlower/toupper/upper(a.c = / >= / <= / > / < literal)(a.c != 'lit')/(a.c <> 'lit')(a.c IS NULL)(a.c IS NOT NULL)(a.c IN ['x', 'y'])((x) OR (y))(NOT (x))(CASE WHEN … THEN … ELSE … END = v)(a.c + 1 = 26),%,/)substring,size,coalesce,toInteger, …)'it\u0027s','C:\u005Cx')=/!=/IS NULL/INon a Categorical columnSTARTS WITH/ENDS WITH/CONTAINS/=~Parity argument
Parity is by construction, not by re-derivation. The fallback this lane replaces is, on a polars frame,
where_rows_polars→lower_expr_str→table.filter(expr).lower_single_alias_predicatecalls the same parser and the samelower_exprunder the same_SCHEMAdtypes; the only difference is the column naming, andalias.col ↔ colis 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:
frame.filter(...), then force_residual_polars_exprto decline and run the realwhere_rowschain 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.NotImplementedError— pinned byTestWrongDtypeStaysResidualandTestStringPredicatesAreUnreachable. 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.lower_expritself, 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.(a.name != 'BOB')with a NULL name yields NULL, andfilterDROPS the row — the same as=. A pandas-shaped object-dtype!=would have kept it. This is pinned per shape (TestWidenedShapesTakeTheNativePath) and end to end throughgfql().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.test_widened_residual_matches_pandas_oracleruns 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
STARTS WITH/ENDS WITH/CONTAINS/=~_pushdown_connected_join_where_filterscannot render these to a row filter, so on the polars engine the whole comma-pattern query is rejected upstream withGFQLValidationErrorand 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_exprhas no native kernel for them either, so the fallback also raises.)(b.age = 25),(a.age + b.age = 2), foreign alias insideNOT/IS NULL/IN/CASE)NotImplementedError.(age = 25))(__gfql_node_id__ = 1),(a.__gfql_node_id__ = 1))_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.(a.missing = 25))NotImplementedErrorfrom the fallback.(a.name != 25),(a.age != 'thirty')), cross-categoryIN((a.age IN ['x']))lower_expr's_is_cross_type/_lower_inguard: polars raises where the evaluator has a designed error. Must stay residual so the designed error is what surfaces, not a raw polarsComputeError.toLower/toUpperon a non-String column, incl. Categorical.stron Categorical raises in polars only; the row lowering gates the case functions on a String output dtype.lower_exprdeclines them anyway.Behaviour change worth calling out
TestFusedTwoStarLane::test_non_ascii_two_sided_declines_the_lane_but_not_the_answerasserted the fused lane declines a two-sidedtoLower(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: thewhere_rowsfallback it deferred to lowers both sides with the same polarsto_lowercasekernel, 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(inbin/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):bin/test-polars.shphase 1bin/test-polars.shphase 2 (test_lowering.py -k polars).github/workflows/ci.yml)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, fourtests/gfql/ref/test_chain_optimizations.py[cudf]params, twotest_df_executor_core.pywithout-cudf cases) that CI's pandas-only gfql-core lane never runs.Lint / typecheck
./bin/ruff.shclean;./bin/typecheck.sh— Success: no issues found in 327 source files.bin/ci_type_hygiene_guard.pyreports 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 master68638794c(python-lint-types3.11/3.13/3.14 are red on that commit) forgfql_fast_paths.py(138 vs cap 133),row_pipeline.py(19 vs 18) andgfql_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
MATCH … WHERE x STARTS WITH 'a'answers onengine='pandas'and is rejected onengine='polars'withGFQLValidationError. The fix is in_pushdown_connected_join_where_filters(plus native string-predicate kernels inlower_expr), not in the residual translator.x IN [...]residual in the same comma-pattern shape raisesGFQLTypeError: Unalignable boolean Series providedonengine='pandas'. The polars fast lane and the polarswhere_rowsfallback agree on it (pinned); the pandas oracle test excludesINfor this reason, with the reason written in the test.🤖 Generated with Claude Code
https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx