Skip to content

perf(gfql): native op-lists seeded on a membership set take the resident index (#2116 3a) - #2127

Merged
lmeyerov merged 16 commits into
masterfrom
perf/gfql-native-membership-seed-3a
Oct 4, 2026
Merged

lmeyerov merged 16 commits into
masterfrom
perf/gfql-native-membership-seed-3a

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #2117 (retarget to master once it lands). #2116 item 3a: [n({"id": is_in(seeds)}), e_forward(), n()] (or a plain list of ids) returned the right rows but ran the isin scan, and gfql_explain recorded nothing (used_index=False, decision_code=None, steps=[]) while the scalar seed n({"id": 5}) and the Cypher twin (WHERE a.id IN [...], #2117) took the index.

Mechanism: chain._try_chain_fast_path admits the shape, but _seeded_scalar_filters bails on the IsIn value, so the native seeded-hop lane returns None and the general chain records nothing for the seed.

Fix, mirroring #2117 rather than forking: _seeded_seed_filters resolves a membership set on the node-id key through the bindings kernel's own _membership_seed_ids; _seed_node_rows_from_index looks the ids up in the node-id index (it already took a list) and skips the property index for them; the scan fallback uses isin; _indexed_kernel_admits counts the tuple as seeded-on-binding. The native seeded-hop and single-node lanes take it; the Cypher RETURN-destination lane stays scalar-only so Cypher membership seeds keep going to the bindings kernel exactly as #2117 pins. The native lane's scan branch now records a decline (index_path_unavailable) instead of leaving explain silent.

Measured at 20k nodes / 100k edges, 50 seeds, pandas (local, direction only): is_in 6.4 ms → 1.4 ms, list 24.4 ms → 1.4 ms; gfql_explain records native_seeded_hop / native_seed_lookup with index_selected under auto/use/force, policy_off under off. Residual filters on the seed node and the destination still apply (135 / 127 rows == truth); non-integral or boolean members and membership on a non-id column keep the scan and its rows. Polars already took the index for this shape via its own route (unchanged).

Test plan

🤖 Generated with Claude Code

https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp

…ent index (3a)

[n({"id": is_in(seeds)}), e_forward(), n()] ran the isin scan with gfql_explain recording
nothing while the scalar seed and the Cypher twin took the index. The native lanes resolve
a membership set on the node-id key through the kernel's own _membership_seed_ids, look the
ids up in the node-id index, and re-apply the canonical filter on the hits.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
lmeyerov and others added 5 commits October 3, 2026 02:08
…engaged)

The routes-off replay does not collect chain_specializations/ today; marking keeps the
contract if it ever does: result pins stay unmarked, used_index/seam assertions are marked.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
… pins + polars-lane registration) into perf/gfql-native-membership-seed-3a
…es serve it

On the real GPU the cuDF engagement pins saw no usable index: the graph was built from
pandas frames and run with engine='cudf', so the resident indexes matched the pandas frames.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
@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) at d7d09f7: 608 passed, 2 failed — the two failures are #2117's new cuDF kernel pins (same pandas-frames-then-cudf cause, reported on #2117 with the fix); this PR's four cuDF engagement pins pass after d7d09f7.

lmeyerov and others added 5 commits October 3, 2026 12:23
… too

On the SNB sentinel fixture the node-id lookup is unavailable and the scalar seed is served
through the property index on id; the membership path skipped that index and fell to the
scan (12.2 ms vs 1.0 ms GFQL-only, pyg-bench arms a3/b3). The property lookup is array-based,
so a tuple of ids slots in; non-integral or empty member sets still decline.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
…e native lane

The SNB sentinel binds the node column under another name and seeds on the id property;
the scalar seed is served through the property index on id, and a membership set on that
column must be too. The lane admits membership sets on any seed column: the node-id index
serves the binding key, a resident property index serves the rest, the scan keeps the rows.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
…redicate still is not

The #2027 pin asserted that no non-scalar seed takes the native lane; parity keeps holding
for both, and is_in now takes the lane by design (#2127).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
@lmeyerov
lmeyerov changed the base branch from perf/gfql-cypher-in-vectorized to master October 3, 2026 20:24
…n is a route_engaged test

Parity stays a result pin for both predicates; served/not-served is an engagement claim and
skips under the routes-off replay like the rest of the file's engagement pins.

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

lmeyerov commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Read-only review (parallel session) — native membership seeds + pyg-bench #285

Nothing here touches the branch; findings only, fixes deferred.

Target: perf/gfql-native-membership-seed-3a @ 7d12173 (12 commits) → master @ 88b99cc · mode=findings · fixes=deferred · 2026-10-04
Companion: pyg-bench #285 bench/sentinel-points-3a-3c → main.
Artifacts: plans/review-pr-2127/ (plan.md, research/, waves/, final-report.md). Scripts + raw outputs in research/rv_*.

Verdict in one line

No blockers. Parity holds on 83 admitted/declined shapes × 4 policies (0 divergences); the 50-seed shape is 4× faster (8.4 → 2.1 ms at 100k/500k pandas); two IMPORTANT items (a CHANGELOG/explain claim the single-node lane does not honour; the real-GPU receipt predates the five commits that extend the lane to property columns) and a handful of suggestions.

Findings — PR #2127

IMPORTANT

F3 — CHANGELOG/PR say both native lanes record a decline; only the hop lane does. CHANGELOG.md ("the native lanes … when no usable index is resident they record that too") vs graphistry/compute/chain_specializations/hotpaths.py:37-39 (if how != "scan": — the single-node lane stays silent on scan) while the hop lane records served=False at hotpaths.py:106-108.
Failing input (pandas, no index built):

g = graphistry.edges(edges, "src", "dst").nodes(nodes, "id")
g.gfql_explain([n({"id": is_in(seeds)})], engine="pandas", index_policy="use")
# → used_index=False, decision_code=None, steps=[]      (matrix cells int_noidx/single_isin, uint_idx/single_isin)

Fix either way: record _record_native_seed_lane(..., served=False, reason="no_valid_resident_index") on the scan branch of _single_node_rows_via_index_or_filter, or narrow the CHANGELOG + PR body to the hop lane.

F9 — GPU evidence is stale for the cuDF-reachable half of the change. The only real-GPU receipt (PR comment: dgx-spark, RAPIDS 26.02, at d7d09f7b3, 608 passed / 2 failed = #2117's pins) predates 5 commits; 159507dd5 and 0811b3dd5 change gfql/index/bindings.py:_seed_rows_via_property_index (tuple arm, xp.asarray(members) runs on cupy) and the lane's property-column admission, and the prop-index tests added after the receipt (test_a_membership_seed_is_served_through_the_property_index…, test_a_membership_seed_on_an_indexed_property_column_is_served, test_property_index_seed_lookup_accepts_only_integral_members) are pandas-only. Per agents/skills/review/SKILL.md (GPU-affecting PR): re-run graphistry/tests/compute/chain_specializations/test_native_membership_seed_3a.py + graphistry/tests/compute/gfql/index cuDF arms at head on dgx-spark (RAPIDS 25.02 and 26.02 preferred) and post the receipt. Locally the 4 cuDF twins fail only with OSError: libnvrtc.so.12: cannot open shared object file (same on the sibling 2027 file's 23 cuDF cells) — environment, not PR.

SUGGESTION

F4 — No cost gate on the native membership lane (measured). hotpaths.py:71-87 always gathers through the resident indexes; the kernel declines at frontier ≥ cost_gate_frac·n_keys (gfql/index/cost.py:16, pandas 0.5). Crossover ≈20% of nodes; at 50% the PR is ~9% slower than master and ~17% slower than its own scan branch:

seeds / nodes master use PR use PR off
0.1% 8.68 ms 2.47 ms 7.23 ms
1% 11.74 5.42 11.36
5% 19.87 11.28 17.18
10% 24.92 19.53 22.76
20% 33.62 34.30 33.16
50% 73.88 80.55 68.83
Reuse cost_gate_frac(engine) on len(members) (or seed_nodes[node].nunique()) before taking ctx. Non-blocking: seed sets covering ≥20% of the node table are not the 3a shape.

F1 — _seeded_seed_filters(fd, df, node_id) never uses node_id (chain_fast_paths.py:120-134); docstring and the SeedFilterValue comment (typing.py:62, "on the node-id key") describe key-dependent behaviour the body does not implement (any present column is admitted). Drop the param or make the docs match.

F2 — _indexed_kernel_admits tuple arm is dead code (admission.py:70). Both callers (hotpaths.py:184→199, gfql/lazy/engine/polars/chain_specializations/hotpaths.py:140→157) build n0f via _seeded_scalar_filters, which returns None for membership values, so isinstance(seed_val, tuple) is never True. The PR body's "_indexed_kernel_admits counts the tuple as seeded-on-binding" describes an unreachable branch; changed-line coverage passes only because it is one line. Delete or add a reaching test.

F5 — Decline reason is a constant. hotpaths.py:107 reports "no_valid_resident_index" for every scan fallback, including when both indexes are resident and valid but the key family mismatches (graph with string ids + is_in([ints]) → _ids_to_key_array declines; matrix cell str_idx/hop_str_ids_intmembers). Thread the actual cause (how, dtype decline, _index_edge_rows None) into reason.

F6 — The new test file is never replayed by the routes-off CI lanes (pre-existing gap, not a regression): bin/test-routes-off.sh:10 SUITES omits graphistry/tests/compute/chain_specializations/, so the 8 route_engaged markers are inert in CI and the file's parity tests only run in test-core-python. Verified locally that the markers are nonetheless correct: GFQL_ROUTES_OFF=native-fast and =index-hop → 3 passed / 8 skipped, 0 divergences. Add the dir to SUITES in a follow-up.

Test gaps (pin the cheap ones): no end-to-end tests for empty is_in([]), absent ids, duplicate members, uint64 ids, string-id graphs with int members, or membership on an un-indexed property column. All hold parity here (matrix), but the empty/duplicate cases ride _ids_to_key_array's float-promotion/unique path and are worth a pin.

Style: hotpaths.py:93 is a 170+-char conditional expression under the module's noqa: E501; split it.

Verified OK (no action)

  • Parity: 83 shapes/graphs × {off, auto, use, force} vs the general path (routes_off({"native-fast"})): 0 divergences — int/uint/float/str ids, indexed/unindexed, property index on/off, empty/absent/duplicate members, float/bool/str/None members (declined to general path), residual predicates (is_in beside the seed, n2 filter, edge_match), reverse/undirected/2-hop/mixed direction, duplicate node rows + NaN endpoint (indexes correctly invalid → decline). research/rv_parity_pr.json.
  • Interaction with 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: a bare native op-list never reaches _try_indexed_connected_bindings_state (kernel entries: chain.py:702 via rows() boundary calls, row/pipeline.py:4107 Cypher, polars), so this is an extension that reuses _membership_seed_ids; the Cypher RETURN-dst lane stays scalar (hotpaths.py:184); cypher parity + 2027 pins green.
  • Explain: served steps carry index_selected; policy off → policy_off; hop-lane decline → index_path_unavailable; recorder is _trace_active()-gated so the hot path pays nothing.
  • Registry identity: get_valid(kind, frame, cols, engine) fingerprint+identity respected (chain_fast_paths.py:192-193, 290); rebind_edges idiom untouched by this PR.
  • Polars: the 3-op shape is served by polars' own route (unchanged, used_index=True, no native seams); the polars single-node lane does share _single_node_rows_via_index_or_filter (polars hotpaths.py:46) and now serves membership through the node-id index — parity OK on 5 shapes incl. residual + property; is_in([]) raises NotImplementedError identically under policy off (pre-existing).
  • cuDF: read only — .isin(list(tuple)), _index_node_rows, take_rows are cuDF-generic; _membership_seed_ids is host-side ints. Needs the receipt (F9).
  • Lint: ./bin/ruff.sh on all 7 changed paths clean. No broad excepts / substring control flow in source; pure-functional; Mapping[str, SeedFilterValue] + DataFrameT typing kept.
  • Suites (PR tree, pandas): new file 11 passed; 2027 file 23 passed; chain_specializations 335 passed; gfql/index 1148 passed, 11 skipped, 2 xfailed. gh pr checks 2127: all green incl. 11 routes-off lanes, changed-line-coverage, cypher differential parity, RTD.
  • Merge state: MERGEABLE (clean merge-tree vs master); master drift since the merge-base touches only typing.py in non-overlapping hunks. Branch merged the 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 branch (d15dd1d) rather than master — fine now that 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 is in master.

Perf table (pandas, 100k nodes / 500k edges, gfql_index_all(), 2 warmups, median of 5, local shared box — direction only)

shape (policy=use) master 88b99cc PR 7d12173 Δ
[n({"id": is_in(50 ids)}), e_forward(), n()] 8.37 ms 2.09 ms 4.0×
same with a plain list of 50 ids 8.77 ms 2.06 ms 4.3×
[n({"id": is_in(50 ids)})] 1.71 ms 1.26 ms 1.4×
[n({"id": 5}), e_forward(), n()] (single seed) 1.24 ms 1.14 ms no regression
[n(), e_forward(), n()] (unseeded) 28.8 ms 27.4 ms no regression
is_in(50% of nodes) hop 63.8 ms 79.1 ms −24% (F4)
Policy off, 50-seed hop: master 8.35 → PR 6.60 ms (the lane's isin scan branch beats the general path).

Findings — pyg-bench #285

  • Checks: aggregate-contract pass, smoke pass; MERGEABLE/CLEAN.
  • The new native-hop-members point measures exactly the perf(gfql): native op-lists seeded on a membership set take the resident index (#2116 3a) #2127 shape (3-op native list, is_in of 10 Message ids + label__Message, HAS_CREATOR, Person) and, because the SNB fixture binds the node column under another name, it is served through the property index on id that the bench builds — i.e. the 0811b3dd5 extension, not the node-id path. Good sentinel; the earlier arms' 12.9 ms plateau is explained (members drawn from the binding column).
  • Thresholds are derived, not typed: every pandas max_median_ms in thresholds-master.json equals the receipt median × 1.5 (checked all 18 pandas rows + both new polars rows); older polars bounds are retained round numbers (pre-existing, declared in _comment). Baseline receipt b18d8089fb1d is a master commit (0.59.1 merge), dgx-spark, 8 warmups / 63 repeats.
  • Candidate file pins pandas/native-hop-members at 22.006 ms (= master scan 14.671 × 1.5), served: true, property_index_served: false (gate enforces only true, gate_snb_point_latency.py:91-93). Consequence: [FEA] typecheck invalid api=... value #285 passes under both master and perf(gfql): native op-lists seeded on a membership set take the resident index (#2116 3a) #2127 trees and does not functionally require perf(gfql): native op-lists seeded on a membership set take the resident index (#2116 3a) #2127 to land first; merging after perf(gfql): native op-lists seeded on a membership set take the resident index (#2116 3a) #2127 is still correct so the next baseline swap can tighten the point (≈3 ms, property_index_served: true). Agree with the PR's stated order.
  • SUGGESTION native_points._index_served: except Exception: return None — narrow, or record the exception type so a broken explain is distinguishable from "not served".
  • SUGGESTION scripts/snb_native_seed_explain_probe.py::_shape_key classifies via "IsIn" in repr(...) — substring control flow in a diagnostic script.
  • SUGGESTION member_ids loops the whole Message id column in Python (setup, untimed); ids[ids > message_id].nsmallest(count - 1) is the vectorized form.
  • Note polars/native-hop-members pins served: false — the gate will demand an update when polars gains the lane (by design).
  • Runner change (dgx_spark_runner.py): results/ now ships only *baseline* dirs; PGBENCH_UPLOAD_KIBPS budget — tests updated (tests/test_dgx_spark_runner.py), fine.

Merge recommendations

PR #2127 — approve after two small follow-ups, no blockers. The change is a real extension of #2117 (any column + property index for native op-lists), correctness is solid (0/83 parity divergences incl. the nasty corners), the claimed win reproduces (4× on the target shape) with no regression on the scalar-seed and unseeded shapes, CI is fully green, and route pins are correctly marked. Before merging: (F3) make the CHANGELOG/explain claim true for the single-node lane or narrow the wording, and (F9) post a real-GPU receipt at head since the last one predates the property-index commits that cuDF can reach. F4 (cost gate, ≤10% regression only for seed sets ≥20% of nodes) and the dead-code/unused-param cleanups (F1, F2, F5) can land here or as a follow-up.

pyg-bench #285 — approve; merge after #2127 as stated. The sentinel measures the right shape, bounds are mechanically derived from a clean master receipt on the same host, the gate semantics make the candidate arm pass on either tree, and the harness fixes are covered by tests. The three suggestions are hygiene in untimed/diagnostic code. The one thing to remember is operational: the post-#2127 baseline swap is what makes native-hop-members a tight 3a sentinel (property_index_served: true, ≈3 ms); until then it only guards against regressing past the scan.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud

…hop lane

Review found the CHANGELOG claim that both native lanes report a declined index held only for
the hop lane: with no usable index resident the single-node lane returned scan rows and
recorded no step at all, so gfql_explain said nothing rather than index_path_unavailable.
Pinned on both engines, served and declined.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
@lmeyerov

lmeyerov commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Both items addressed at fdcfd16.

F3 — CHANGELOG says both lanes record a decline; only the hop lane did. Confirmed at chain_specializations/hotpaths.py:37, and fixed the first way you offered: the single-node lane now records served=False with no_valid_resident_index on the scan branch, so your input reports used_index=False, decision_code=index_path_unavailable, steps=['native_seed_lookup'] instead of nothing at all. Pinned both ways on pandas and cuDF in test_the_single_node_lane_records_its_decline_too — declined with no index resident, served once gfql_index_all() has run.

F9 — GPU receipt predates the property-column commits. Confirmed: the receipt is at d7d09f7b3 and 159507dd5 / 0811b3dd5 came later and touch the cuDF-reachable _seed_rows_via_property_index. A fresh receipt at this head is queued for dgx-spark on the 26.02 image, covering test_native_membership_seed_3a.py and the gfql/index cuDF arms. Locally the cuDF twins fail only on libnvrtc.so.12, which reproduces on master.

On the suggestions, for the record rather than as changes here: F4's cost gate is a real crossover at roughly 20% of the node table and I would rather gate it in its own PR with the measurement than add an untested threshold now; F1's unused node_id parameter and F5's constant decline reason are small and queued with it; F2's tuple arm in _indexed_kernel_admits is indeed unreachable from today's callers; F6 (routes-off SUITES omits tests/compute/chain_specializations/) is a CI gap worth its own one-line fix, and I verified the markers behave when the suite is replayed by hand.

Separately, my own review pass flagged something on the companion pyg-bench PR that I am treating as a blocker there, not here: the swapped baseline's polars medians are 2.4 to 3.4 times the other master runs and that run's own gate-report calls them drift failures. I am re-running the baseline before asking for #285 to be merged.

lmeyerov and others added 2 commits October 4, 2026 01:46
The new pin asserts the lane's own explain step, so it must skip when that lane is declined:
without the marker it failed the all-routes-off replay. Also merges master, which the branch was
20 commits behind.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
@lmeyerov

lmeyerov commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

F9 is answered with a receipt at the real head.

dgx-spark, GB10, graphistry/test-rapids-official:26.02-gfql-polars, head 806deb9 (which now has master merged in; the branch had fallen 20 commits behind):

graphistry/tests/compute/chain_specializations
graphistry/tests/compute/gfql/index/test_indexed_bindings_membership_seed.py
graphistry/tests/compute/gfql/test_native_seed_resolution_2027.py
  -> 632 passed, 0 failed   (EXIT=0)

A first pass at the pre-merge tree (fdcfd16) failed two cuDF cells, test_a_cypher_in_list_is_served_by_the_bindings_kernel[cudf] and test_two_hops_from_a_seed_set_are_served_too[cudf], both assert [] == ['connected_bindings']. Those are master's own tests and they pass on master tip at 88b99cc on the same GPU (20 passed), so they were an artifact of the half-merged tree, not of this change. The receipt above is the one that counts.

The run also surfaced an unrelated product limitation I am pinning on #2122 rather than here: gfql_index_all() over a string-keyed cuDF graph raises a raw TypeError: cupy does not support object instead of declining. An int-keyed cuDF graph indexes normally, and the plain query answers either way. Worth its own fix as a typed decline.

@lmeyerov
lmeyerov merged commit b718abc into master Oct 4, 2026
82 checks passed
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