Skip to content

Commit bf1e30f

Browse files
lmeyerovclaude
andcommitted
perf(gfql): translate the one-sided toLower residual natively (literal as written)
`_residual_polars_expr` recognized only the two-sided `toLower(a.col) = toLower('lit')` shape. The Cypher renderer emits `toLower(a.col) = 'lit'` verbatim -- there is no constant folding of `toLower('lit')` -- so the equally idiomatic one-sided spelling failed to translate. A SINGLE untranslatable residual declines the whole fused single-collect two-star plan, so one such predicate dropped the query onto the eager per-op-collect path AND onto the where_rows chain evaluator for every alias. Semantics are the evaluator's, established by measurement before writing code: `toLower(x)` folds the COLUMN, and a bare literal is compared AS WRITTEN, so `toLower(x) = 'MALE'` matches zero rows on both the pandas and the polars evaluators. The two-sided arm keeps folding the literal; the one-sided arm folds nothing in Python. Lowercasing the one-sided RHS would be a silent wrong answer that passes every lowercase-literal benchmark query, so mixed-case parity is pinned directly against the general path. Every existing decline guard applies unchanged to the new shape: escaped literals (\uXXXX), non-string and Categorical columns, alias mismatch, absent column. Newly qualifying is exactly one residual string shape, `(tolower(<alias>.<col>) = '<literal>')`; reversed operand order, a column RHS, toUpper/lower/upper, other operators, NOT-wrapping and compound predicates all still decline. Measured on the real graph-benchmark dataset (dgx-spark GB10, polars, perf lock held, ABBA/BAAB, 9 runs/slot, values identical in every slot). One-sided form, 20k: q5 8.15/8.31 -> 4.17/4.61 ms, q6 12.55/13.06 -> 6.29/4.92, q7 9.40/10.39 -> 5.87/5.91. 100k: q5 18.06/17.38 -> 15.30/12.30, q6 25.54/21.35 -> 20.93/13.88, q7 19.47/16.87 -> 11.47/11.16. Non-overlapping slot ranges in all twelve cells. The two-sided form was measured as a control in the same session and TIES at both scales (overlapping ranges), so no published benchmark cell moves. Locked in by a structural probe -- the fused lane must serve exactly once for a one-sided residual query -- not by a scaling ladder: the removed cost is a per-op constant, so a growth-ratio gate would police the wrong direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB
1 parent 233b64c commit bf1e30f

3 files changed

Lines changed: 209 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1414
- **GFQL secondary node property indexes (`create_index('node_prop', column=...)` / `g.gfql_index_node_props([...])`)**: a seed predicate on a NON-key column — `MATCH (m {id: 42})` where the graph's node id binding is some other column — previously cost a full node scan, because the registry only indexed the node-id binding and the CSR adjacencies. A property index is the same pay-as-you-go sidecar as the existing kinds: sorted distinct values over node **row positions** (CSR, so duplicate values are indexable), never reorders `.nodes`, fingerprint-validated so a `.nodes()` rebind is treated as absent (safe miss, never a wrong answer), engine-polymorphic (numpy host / cupy on-device), and policy-gated (`off`/`use`/`auto`/`force`). The seeded fixed-hop planner picks the **most selective** indexed scalar predicate in the seed filter using a free CSR-offset estimate, gathers those candidates, and applies every remaining predicate to them — so results are identical whether the index is present, absent, stale, or cost-gated out. `show_indexes()` lists property indexes; `drop_index('node_prop', column=...)` drops one. Only integer columns are indexable today (float NaN ordering, strings on cupy, and nulls all decline to the scan); widening that is additive. **Perf (dgx-spark, official LDBC SNB SF1, 3.18M nodes, warm median, value-identical 19-row result):** interactive-short IS7 `71.6 ms -> 19.5 ms` (**3.7x**), with a one-time `112 ms` build — the seed lookup itself goes from a `51.2 ms` scan to `0.096 ms`.
1515

1616
### Performance
17+
- **Connected-join residuals translate the ONE-SIDED `toLower(a.col) = 'lit'` form natively**: the native polars residual translator only recognized the two-sided `toLower(a.col) = toLower('lit')` shape, so the equally idiomatic one-sided spelling — which the Cypher renderer emits verbatim, there is no constant folding of `toLower('lit')` — failed to translate. A *single* untranslatable residual declines the whole fused single-collect two-star plan, so one such predicate dropped the query onto the eager per-op-collect path AND onto the `where_rows` chain evaluator for every alias. Measured on the real graph-benchmark dataset (dgx-spark GB10, polars, perf lock held, position-balanced A/B/B/A and B/A/A/B, 9 runs per slot, values identical in every slot): at 20k nodes / 260k edges q5 `8.15/8.31 → 4.17/4.61 ms`, q6 `12.55/13.06 → 6.29/4.92 ms`, q7 `9.40/10.39 → 5.87/5.91 ms`; at 107k nodes / 2.78M edges q5 `18.06/17.38 → 15.30/12.30 ms`, q6 `25.54/21.35 → 20.93/13.88 ms`, q7 `19.47/16.87 → 11.47/11.16 ms` — non-overlapping slot ranges in all twelve cells. **Queries written in the two-sided form are unchanged** (they already took the fused lane): the same A/B measured them as a control and every slot range overlaps at both scales, so no published benchmark cell moves. The semantics are the ones the evaluator already had, not a convenient reinterpretation: the two-sided form case-folds the literal, the one-sided form compares against the literal **as written**, so `toLower(x) = 'MALE'` correctly matches nothing. Lowercasing the one-sided right-hand side would have been a silent wrong answer that passes every lowercase-literal benchmark query, so mixed-case parity against the `where_rows` evaluator is pinned directly (`'MALE'` / `'Male'` / `'male'`, plus empty-string, German `STRASSE`/`straße` and Turkish dotted-capital-I cases). All existing decline guards apply unchanged to the new shape — escaped literals (`\uXXXX`), non-string and Categorical columns, alias mismatches and absent columns still fall back to the evaluator and its designed `NotImplementedError`. Locked in by a structural probe (the fused lane must serve exactly once for a one-sided residual query; `0` means the translation rotted), not a timing gate.
1718
- **Native polars chain combine is proportional to the traversal result, not to the graph**: two graph-sized terms sat inside a combine whose answer is a handful of rows, and both are gone. (1) `_combine_edges` ran the prev/next endpoint gates for EVERY step, including the node steps whose edge frame is `g._edges.clear()` — zero rows. The eager combine skipped those, but the collect-once rewrite lazified the step frames and `.lazy()` erases the height, so the skip silently went dead. The cost lands on the side that is NOT empty: for the first step the gate's key side is the whole node table, and polars builds the hash table on that side before discovering the probe side has no rows (isolated: 6.99 ms for one such join at N=2M, and a chain pays one per node step). The pre-lazy row count is now recorded when the step frame is still eager and an empty step is dropped from the id union — it can contribute no ids, so the result is unchanged by construction. The skip keys on KNOWN-empty only; a frame that arrives already lazy reports no height and is planned normally. (2) The output node rows were materialized in TWO passes over the node table — one for the ids the steps kept, one more for the surviving edges' endpoints the first pass missed — then concatenated. The output node set is the UNION of those two id sides, so the ids are unioned first and the node table is read ONCE. The row-level `unique(subset=[node])` is preserved verbatim: those rows feed `how="left"` alias joins where a node table carrying the same id twice would multiply rows. Measured on synthetic LDBC-IS5-shaped graphs (one-row answer, polars-engine resident indexes), varying one dimension at a time: **at fixed E=2M, N=250k → 4M went 10.17 → 45.48 ms before and 7.60 → 17.17 ms after (2.65× at 4M, and the node-count slope is 3.7× flatter)**; the edge-count slope is unchanged, as expected for a node-side fix, with the constant ~6 ms lower. Parity: identical full frames (all columns, row order included) across 280 shape × graph combinations and 400 duplicate-node-id combinations, plus the 1003-case polars chain differential suite. Pinned by tests that assert the boundary rather than a wall clock: the node universe must not appear in the edge plan at all, and the node table must be read at most once per query.
1819
- **GFQL polars chain stops deduplicating semi-join key sides**: the native polars executor applied `.unique()` to every frame it fed into a `how="semi"` join. A semi-join emits a left row iff at least one matching right row exists, so duplicate keys can neither change which rows come back nor multiply them the way an inner join would — the deduplication was a full hash pass over the key column bought for no observable effect. On an unfiltered hop the key side **is** the node table, so this put **O(N) work inside a query whose answer is O(degree)**: the seeded single-hop plan built two such key frames per hop, each costing ~53 ms at 3.18M nodes — more than the rest of the query combined. The `.unique()` is now dropped everywhere the frame is provably a semi key side only (the `_semi` helper, the alias hop-window and next-edge endpoint gates, the two-hop fast path's endpoint gate, the `start_nodes` gate, the single-hop planner's id frames, and the index layer's `select_by_ids` polars branch — where the cuDF and pandas branches already used `isin` with no dedup, so the three engines now agree). It is deliberately **kept** on the alias frame that feeds a `how="left"` join, where duplicates genuinely would multiply rows, and the eager multi-hop loop is untouched (its frames also flow into concat/anti-join bookkeeping, a separate argument). Measured on a 3.18M-node / 14M-edge polars graph (LDBC SNB SF1-shaped), a seeded typed hop goes **127.4 → 57.1 ms (2.23×)** with identical row counts; at 1.75M edges **102.8 → 31.4 ms (3.28×)**, confirming the removed cost scales with node count, not edge count. Parity held across 380 differential comparisons covering duplicate node keys, null ids, dangling edges, duplicate `start_nodes`, and 11 traversal shapes; the gfql and chain suites show identical failure sets before and after. Pinned by tests that assert the boundary rather than the speed: duplicates reaching a semi key side must not change results or multiply rows, a dangling endpoint must still be excluded (the gate is load-bearing, not vacuous), and duplicate `start_nodes` must be inert.
1920
- **Seeded chain combine stops joining against the full frame when the intermediate is empty**: `_lean_prefilter_right` shrinks the big side of the combine's `how='left'` merge to the keys actually present on the left, but it declined to do so in the one case where shrinking is both maximally profitable and trivially correct — an **empty** left. A left merge keeps only the right rows that match, so a zero-row left yields a zero-row result whatever the right side holds; the merge was nonetheless materializing the whole graph-sized frame. It now hands back a zero-row slice of `right` (same columns and dtypes, so the merge still produces an identical schema). Measured: a single-node query whose 0-row intermediate was joined against 14M edges went **112.64 → 17.29 ms**. The shrink is used at exactly one call site, which is `how='left'`; a merge that retains unmatched right rows (`right`/`outer`) would NOT be safe to shrink this way, and the tests pin both directions — the empty-left case must return an empty result, and the non-empty cases must be byte-identical to the unshrunk merge.

graphistry/compute/gfql_fast_paths.py

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,8 @@ def _connected_join_two_star_split_residuals(
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.
617617
_RESIDUAL_TOLOWER_EQ = re.compile(
618-
r"^\(tolower\((?P<alias>\w+)\.(?P<col>\w+)\) = tolower\('(?P<lit>[^']*)'\)\)$"
618+
r"^\(tolower\((?P<alias>\w+)\.(?P<col>\w+)\) = "
619+
r"(?:tolower\('(?P<folded_lit>[^']*)'\)|'(?P<verbatim_lit>[^']*)')\)$"
619620
)
620621
_RESIDUAL_SCALAR_CMP = re.compile(
621622
r"^\((?P<alias>\w+)\.(?P<col>\w+) (?P<op>=|>=|<=|>|<) "
@@ -624,7 +625,7 @@ def _connected_join_two_star_split_residuals(
624625

625626

626627
def _residual_polars_expr(
627-
expr: str, alias: str, schema: Mapping[str, Any]
628+
expr: str, alias: str, schema: NodeDtypes
628629
) -> Optional['pl.Expr']:
629630
"""Translate a simple residual to a native polars expression, or None to fall back.
630631
@@ -633,16 +634,20 @@ def _residual_polars_expr(
633634
``(tolower(a.col) = tolower('lit'))``), not typed AST terms — so string parsing here
634635
is the honest interface; a typed term would require a lowering-level refactor.
635636
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:
637+
Covered (exactly the #1729 scalar-residual shapes): ``(tolower(a.col) = tolower('lit'))``,
638+
``(tolower(a.col) = 'lit')`` and ``(a.col <op> literal)`` for ``= >= <= > <``. Semantics
639+
match the where_rows evaluator on these shapes: string compares are null-safe (null ->
640+
filtered out, since polars comparisons on null yield null which ``filter`` drops, same as
641+
the evaluator's null-propagating comparisons); toLower equality lowercases the column via
642+
polars ``str.to_lowercase()``. The TWO-SIDED form additionally folds the literal via
643+
Python ``str.lower()`` (empirically equal on the ASCII/latin shapes the lowering emits; a
644+
divergence would need a Rust-vs-Python Unicode table drift). The ONE-SIDED form compares
645+
against the literal AS WRITTEN and folds NOTHING in Python -- the evaluator does not
646+
lowercase a bare literal, so ``toLower(x) = 'MALE'`` correctly matches no rows (measured
647+
on both the pandas and polars evaluators). Float NaN ranking differs between polars and
648+
the evaluator, but gfql ingest normalizes NaN->null (``_pl_nan_to_null``) so NaN never
649+
reaches this filter through ``gfql()``.
650+
Declines (returns None, caller uses the chain fallback) on:
646651
any other shape, non-matching alias, a column absent from the schema, an ESCAPED
647652
string literal (``\\`` — the renderer escapes ``' \\ \\n`` etc. to ``\\uXXXX`` which the
648653
evaluator unescapes; raw comparison would silently mismatch), and dtype-incompatible
@@ -661,14 +666,23 @@ def _is_numeric_dtype(dtype: Any) -> bool:
661666
m = _RESIDUAL_TOLOWER_EQ.match(expr)
662667
if m is not None:
663668
col_name = m.group("col")
664-
tolower_lit = m.group("lit")
665-
if m.group("alias") != alias or col_name not in schema:
669+
# Exactly one alternative binds; `folded_lit` distinguishes them.
670+
folded_lit: Optional[str] = m.group("folded_lit")
671+
verbatim_lit: Optional[str] = m.group("verbatim_lit")
672+
raw_lit: Optional[str] = folded_lit if folded_lit is not None else verbatim_lit
673+
if m.group("alias") != alias or col_name not in schema or raw_lit is None:
666674
return None
667-
if "\\" in tolower_lit:
675+
if "\\" in raw_lit:
668676
return None # escaped literal: let the evaluator unescape it
669677
if not _is_string_dtype(schema[col_name]):
670678
return None # tolower on non-string column: evaluator raises designed NIE
671-
return pl.col(col_name).str.to_lowercase() == tolower_lit.lower()
679+
# Two-sided `= tolower('LIT')` case-folds the literal; ONE-SIDED `= 'LIT'` must
680+
# compare against the literal AS WRITTEN, because the where_rows evaluator does
681+
# NOT lowercase a bare literal (measured, both engines: `toLower(x) = 'ALICE'`
682+
# matches zero rows). Lowercasing the one-sided RHS here would silently return
683+
# rows the general path excludes.
684+
rhs = raw_lit.lower() if folded_lit is not None else raw_lit
685+
return pl.col(col_name).str.to_lowercase() == rhs
672686
m = _RESIDUAL_SCALAR_CMP.match(expr)
673687
if m is not None:
674688
col_name = m.group("col")
@@ -712,7 +726,8 @@ def _connected_join_apply_node_residuals(
712726
"""Filter a fast-path node frame by single-alias post-join residual expressions.
713727
714728
Fast lane (polars): the simple scalar shapes the #1729 lowering emits
715-
(``tolower(a.col) = tolower('lit')``, ``a.col <op> literal``) translate directly to
729+
(``tolower(a.col) = tolower('lit')``, ``tolower(a.col) = 'lit'``,
730+
``a.col <op> literal``) translate directly to
716731
native polars filters — no chain dispatch (the where_rows chain costs ~1.7ms/alias,
717732
the dominant cost of the residual OLAP fast path). Any expression outside those
718733
shapes falls back to the chain evaluator below, so semantics never diverge.

0 commit comments

Comments
 (0)