Skip to content

perf(gfql/cypher): multi-seed WHERE a.id IN [...] hop 4,004 ms → 10 ms (vectorized IN, index reaches the row pipeline, IN seeds the pattern, kernel takes a seed set) - #2117

Merged
lmeyerov merged 15 commits into
masterfrom
perf/gfql-cypher-in-vectorized
Oct 3, 2026
Merged

lmeyerov merged 15 commits into
masterfrom
perf/gfql-cypher-in-vectorized

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

What

Four composable changes found while chasing #2116 (the index_adjacency docs page's multi-seed example scanning instead of using the index). Measured on 100k nodes / 500k edges, pandas, 50 seeds, MATCH (a)-[e]->(b) WHERE a.id IN [...] RETURN b, local dev box, median of 7:

tree indexes resident no indexes
master 37d7d9b6a 4,004 ms 4,035 ms
+ vectorized IN lane (commit 1) 101 ms 101 ms
+ index migrates onto the pipeline's edge frame (commit 2) 67 ms 106 ms
+ IN [literals] seeds the pattern (commit 3) 19.7 ms 62 ms
+ bindings kernel accepts a membership seed (commit 4) 10.5 ms 62 ms
  1. row/pipeline.py::_gfql_eval_in_expr evaluated x IN [...] with a Python loop over rows × list elements (_gfql_cypher_value_equal, 5M calls for 100k rows × 50 seeds). A constant list of plain scalars now goes through Series.isin plus two masks that reproduce the loop's three-valued table (null row, null element, empty list, NaN as a value). Lists/maps keep the structural-equality loop. Pinned against the loop it replaces on 22 dtype/null shapes.
  2. _gfql_connected_bindings_state tags the edge frame with a per-edge identity column; the new frame object missed the registry's identity guard, so every hop under a Cypher query scanned while the same native hop was index-served. The adjacency indexes now migrate via rebind_edges (chain.py's idiom). gfql_explain reports the hop as index for int and string ids; a permuted edge frame the index was not built over still scans.
  3. Lowering: WHERE alias.prop IN [scalar literals] (node or edge alias, $param lists) becomes is_in([...]) on the pattern, the way alias.prop = literal already does, and leaves the row WHERE. Lists with null, and anything under OR/NOT/nested, stay as where_rows. Parity pinned on IN+AND, edge IN, OR, null lists, inline-eq ∧ IN hit/miss, params, RETURN a, b, OPTIONAL MATCH null row, int and string ids.

Verified locally

  • bin/lint.sh (ruff + type-hygiene + comment-density guards), bin/ci/ci_cypher_surface_guard.py (the pass lives in where_membership.py so lowering.py stays under its line ratchet), mypy: clean.
  • tests/compute/gfql/{cypher,index,row} + test_lowering.py (non-cudf): 3495 + 1443 passed; the cudf-parametrized and polars-conformance cases fail identically on master here (no GPU / local polars) — not touched.
  • New pins: test_in_list_literal_lane.py (37), test_cypher_row_pipeline_reaches_index.py (3), test_in_list_seeds_the_pattern.py (12), test_indexed_bindings_membership_seed.py (12). Full tests/compute/gfql/{cypher,index,row} + chain_specializations (non-cudf): 5273 passed.
  1. Indexed bindings kernel (index/bindings.py): a membership set of integral ids on the node-id key is a seed, resolved through the same node_id lookup as a scalar; the cost gates are unchanged (a seed set covering 90% of the graph still scans), string ids keep declining and take the indexed hop. gfql_explain reports connected_bindings served and the unseeded middle no longer runs. This is the piece GFQL: Cypher multi-seed hop (WHERE a.id IN [...]) does not use the resident adjacency index; single-seed does #2116 asked for.

cuDF / polars validation (dgx-spark, graphistry/test-rapids-official:26.02-gfql, cuDF 26.02, polars 1.35)

  • IN lane vs the element loop on cudf.Series: int, int+null, str+null, float NaN, empty list, only-null list all identical. One divergence found and closed: cudf.Series([True, False]).isin([1]) is all-False where pandas and the loop say True == 1; the lane now leaves any bool/non-bool mix to the loop (commit 5).
  • Query parity vs raw pandas on cuDF and polars, same 100k/500k graph: IN[50], IN+AND, edge IN, IN OR, IN with a null element, inline-eq ∧ IN miss — all equal. cuDF IN[50]: 39 ms plain / 19 ms indexed; polars: 33 ms / 5.4 ms indexed.
  • test_lowering.py cudf+polars params and the index suites inside the image: 463 passed.
  • Pre-existing and unchanged: a list holding null keeps the prefilter route, which polars rejects (NotImplementedError: polars engine cannot natively honour rows(alias_prefilters=...)) on master and here alike.

Drift budget: re-measured, not waived

#2017 added policy.max_compute_commit_drift = 12 (graphistry/compute commits since a run's measured commit). Master sat at 8; this branch adds 8. The shipped GraphBench 20k/100k and SNB boards were therefore re-measured at this tree (2d64913b5) under the published protocol — pinned cpuset 5-9,15-19 / 20 polars threads / harness e6dbf8f8 for GraphBench; 3 process runs × {polars,pandas} × {SF0.1,SF1} on harness 684c0b409 for SNB, competitor arms carried byte-verified — and republished from pyg-bench (graphistry/pyg-bench#283). The vendored artifact's drift for those four runs is 0; docs/test_bench_numbers.py 40 passed.
Numbers vs the f283a30 board: GraphBench within run-to-run spread (100k polars q1 26.00 vs 25.74, q5 7.66 vs 7.57, q8 still the one LOSE cell); SNB every GFQL cell within ±2.5 % or faster (polars SF0.1 message-replies −18.7 %, tag-cooccurrence −20.5 %, new-topics −9.8 %). The export needed no accepted_regressions. The 20k lane is slot-merged (7 published slots re-measured after a foreign campaign contended them; VALIDITY-MERGED.json in the artifact).

Not in this PR

🤖 Generated with Claude Code

https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud

lmeyerov and others added 3 commits October 2, 2026 13:56
…ment Python loop

The row pipeline evaluated `IN` by calling _gfql_cypher_value_equal for every
(row, element) pair: 100k alias rows x 50 seeds = 5M Python steps, ~4 s, on a
query whose answer the native is_in chain produces in 8 ms. A constant list of
plain scalars now goes through Series.isin with the loop's three-valued rules
(null row, null element, empty list, NaN as a value) re-derived as masks; lists
and maps keep the structural-equality loop. The rules are pinned against the
loop they replace in test_in_list_literal_lane.py.

Found while verifying the multi-seed example for the index_adjacency docs (#2116).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
…sident adjacency index

The bindings builder tags the base edge frame with a per-edge identity column
before hopping. That derivation keeps every row in place, but the new frame
object missed the index registry's identity guard, so every hop under a Cypher
query scanned while the same native hop was served. Migrate the adjacency
indexes onto the identified frame (the chain executor's rebind_edges idiom);
gfql_explain now reports the hop as `index` for int and string ids, and an edge
frame the index was not built over still scans.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
…TCH pattern

A literal equality on a MATCH alias already lowers onto the pattern's filter
dict; a literal list did not, so `WHERE a.id IN [...]` reached the executor as
a post-join row filter and the middle `(a)-[e]->(b)` ran unseeded over every
edge before the bindings path recomputed it. Each top-level AND conjunct of the
form `alias.prop IN [scalars]` (node or edge alias, $param lists included) now
becomes `is_in([...])` on the pattern and leaves the row WHERE. Lists holding
null keep their three-valued evaluation; OR, NOT and nested lists stay put.

With the vectorized IN lane and the index migration this takes the 50-seed hop
on 100k nodes / 500k edges (pandas) from 4,004 ms on master to 20 ms with the
indexes resident and 62 ms without.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
…ed on the node-id column

The kernel admitted one integral id as the seed and declined is_in / list seeds,
so a Cypher `WHERE a.id IN [...]` (now lowered onto the pattern) still ran the
unseeded middle before the bindings path recomputed it. A membership set of
integral ids on the node-id key now resolves through the same node_id lookup;
every other op keeps the scalar-only gate and the cost gates are unchanged, so
a seed set covering most of the graph still scans. String ids keep declining
(integer-key kernel) and are served by the indexed hop instead.

100k nodes / 500k edges, pandas, 50 seeds: 19.7 ms -> 10.5 ms with the indexes
resident; gfql_explain reports connected_bindings as served.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
@lmeyerov lmeyerov changed the title perf(gfql/cypher): multi-seed WHERE a.id IN [...] hop 4,004 ms → 20 ms (vectorized IN, index reaches the row pipeline, IN seeds the pattern) perf(gfql/cypher): multi-seed WHERE a.id IN [...] hop 4,004 ms → 10 ms (vectorized IN, index reaches the row pipeline, IN seeds the pattern, kernel takes a seed set) Oct 2, 2026
lmeyerov and others added 3 commits October 2, 2026 14:33
…he element loop

Validated on dgx (cuDF 26.02): cudf.Series([True, False]).isin([1]) is all
False where pandas and the element loop say [True, False] (Python True == 1).
A probe that mixes bool and non-bool, or whose bool-ness differs from the
column's dtype, now falls back to the loop; bool against a bool column keeps
the lane.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
…ship.py

lowering.py is under a line-count ratchet (cypher-frontend-surface-guard); the
pass only needs a one-line hook there.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
…age baseline

The parser always hands the pass a PropertyAccessExpr and a ListLiteral, so the
Identifier and parameter-list fallbacks were unreachable and are gone; the two
remaining declines (a list held by another alias, an alias outside the pattern)
are pinned. 97% under the pin files alone; floor 96.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
lmeyerov and others added 2 commits October 2, 2026 16:08
… pins are route_engaged

`datetime('...')` lowers to zoned ISO text before the membership pass saw it, so
`n.ts IN [datetime('...')]` was pushed as a string is_in and matched nothing where
the row path compares instants (test_b4_in_list_of_temporal_literals). The pass
now keeps zoned ISO text in the WHERE, exactly where `=` already keeps it.

The new served-by pins assert that a route serves, so they carry
@pytest.mark.route_engaged and skip in the gfql-routes-off replay instead of
reporting as divergences.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
lmeyerov and others added 2 commits October 3, 2026 00:13
…e-add the IN bullet under [Development] after the 0.59.1 cut)
…bench #283)

The docs policy caps graphistry/compute commit drift at 12 since a published run's
measured commit; this branch adds 8 on top of master's 8. Instead of a waiver, the
GraphBench 20k/100k and SNB boards were re-measured at 2d64913 under the published
protocol and republished from pyg-bench (#283, same contract v3). Drift for the four
re-measured runs is now 0; the bench-provenance directives name the new run ids.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
lmeyerov and others added 3 commits October 3, 2026 10:18
…duces, across engines

Peel: a bool element and a text/number mixed list stay in the WHERE (True == 1 and
coercion differ between pandas and cuDF isin); an empty list stays too (NeverMatch has
no wire form for the bindings op). Positive pins for float lists, duplicate ids, text ids;
negative pins for each decline. End-to-end parity now runs on pandas, polars and cuDF.

Kernel: parity on every engine with served-by asserted where the kernel is dispatched;
absent seed ids are simply unmatched; a label beside the seed set still goes through.

Lane: shape parity against the element loop on cudf.Series, and the bool-vs-int decline,
when cuDF is runnable (probe exercises cupy: a CPU box can import cuDF and still not run
the index kernels).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
…hem in the polars lane

polars is installed only in the test-polars lane, so a polars param must importorskip
there (the repo idiom in test_indexed_bindings.py), and a module that mentions polars must
be in bin/test-polars.sh's file list (test_polars_lane_completeness). cuDF params skip
unless cupy can run, which is what the index kernels need.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
…efore indexing

An index is built for the frame it sees; querying pandas-built indexes with
engine='cudf' is a foreign-index decline by design (dgx run showed served=[] with
parity intact). Build engine-native frames first, as test_indexed_bindings.py does.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
@lmeyerov

lmeyerov commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Real-GPU receipt (dgx-spark, graphistry/test-rapids-official:26.02-gfql-polars, cudf 26.02.01 / cupy 13.6.0 / polars 1.35.2) on #2127's tree, which includes this branch at 3fc45c1:

  • test_in_list_seeds_the_pattern.py, test_in_list_literal_lane.py, test_cypher_row_pipeline_reaches_index.py: all pass.
  • test_indexed_bindings_membership_seed.py: 2 failed, both the new cuDF params — test_a_cypher_in_list_is_served_by_the_bindings_kernel[cudf] and test_two_hops_from_a_seed_set_are_served_too[cudf] — assert [] == ['connected_bindings'].

Cause (verified on my own pins, which failed identically and pass after the change): the graph is built from pandas frames and then run with engine='cudf'; gfql_index_all() built the resident indexes for the pandas frames, so after the engine coercion no valid index matches and the query scans. The GPU-green index tests build from cudf.from_pandas(...) frames before indexing (test_index.py:704, test_indexed_bindings.py:79). On #2127 the fix was: _graph(engine) converts edges/nodes with cudf.from_pandas when engine == "cudf", then gfql_index_all() (d7d09f7 — 608 passed on the GPU after it). The same two-line change in _graph() here should clear these two; I did not touch your test file.

@lmeyerov

lmeyerov commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

At 9731d49 two routes-off cells are red — gfql-routes-off (all-off) and gfql-routes-off (indexed-kernel) — both on the same two new pins in test_indexed_bindings_membership_seed.py:

  • test_a_label_beside_the_seed_set_still_goes_through_the_kernel
  • test_seed_ids_absent_from_the_graph_are_simply_unmatched

Each asserts _served(...) == ["connected_bindings"], i.e. that the kernel serves. That is an engagement pin by the conftest contract (graphistry/tests/conftest.py): under GFQL_ROUTES_OFF the replay declines the route on purpose, so the result stays right but the seam is empty (or, on a tree where the native lane admits membership seeds, native_seeded_hop), and the pin fails as a false divergence. Your neighbouring pins in the same file carry @pytest.mark.route_engaged("indexed-kernel") and skip there; these two need the same marker. #2127 inherits the two reds through its base.

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