fix(gfql): index AUTO preserves resident polars frames (kills the polars-hop O(E) tax) - #1767
fix(gfql): index AUTO preserves resident polars frames (kills the polars-hop O(E) tax)#1767lmeyerov wants to merge 6 commits into
Conversation
…E) tax) resolve_engine(AUTO) maps polars frames to PANDAS, so create_index coerced-and-replaced the user's polars frames with pandas copies; every later hop(engine='polars') re-converted the full frames per call (O(E), ~220ms at 4M edges) and the pandas-engine index could never fingerprint- match. Resolve AUTO over resident polars frames to POLARS in the index API only (explicit engines unchanged; NOT the global auto-policy change). dgx (warm median 30, parity vs pandas indexed oracle): polars seeded 1-hop 221 -> 0.80ms at 4M (276x); ladder 0.17/0.35/0.80/0.83ms at 0.25M/1M/4M/16M edges vs previously linear 16.6 -> 1751ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
…rialize-first Review findings (plans/review-pr-1767/review.md): B1 edges-only polars graph crashed in the NODE_ID build (materialize under AUTO synthesizes pandas; polars gathers ran on a pandas frame) — gfql_index_all now materializes + engine-aligns BEFORE any build so all indexes land valid, and create_index re-aligns for direct calls. M1 LazyFrame frames under AUTO crashed — the gate now requires EAGER polars DataFrames on ALL present frames (LazyFrame/mixed keep the legacy pandas path). Spy test asserts the index SERVED (non-None), not merely was called. AUTO-query trade-off documented in CHANGELOG (deferred to #1743 by policy hold). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
…1755) With gfql_index_all() resident, the seeded helpers replace their three full-frame scans (O(N) seed row, O(E) frontier isin, O(N) endpoint gather) with node-id searchsorted + CSR adjacency gathers. Decline-gated to the identical scan body: absent/stale index (get_valid fingerprint + identity), non-numeric id families, seed not id-filtered. Local 50k/200k covered shape: pandas 3.11->1.85ms, polars 1.74->1.10ms; win scales with graph size. Pinned: parity+engagement (pandas/polars), string-id decline, stale-index decline. Stacked on perf/gfql-index-preserve-polars-frames (#1767) — polars index residency requires it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
…1755) With gfql_index_all() resident, the seeded helpers replace their three full-frame scans (O(N) seed row, O(E) frontier isin, O(N) endpoint gather) with node-id searchsorted + CSR adjacency gathers. Decline-gated to the identical scan body: absent/stale index (get_valid fingerprint + identity), non-numeric id families, seed not id-filtered. Local 50k/200k covered shape: pandas 3.11->1.85ms, polars 1.74->1.10ms; win scales with graph size. Pinned: parity+engagement (pandas/polars), string-id decline, stale-index decline. Stacked on perf/gfql-index-preserve-polars-frames (#1767) — polars index residency requires it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
Refresh the GFQL performance and engine-selection docs to lead with numbers measured on the 0.58.0 release tag (DGX Spark GB10, warm medians N=30, results verified identical across engines): - Seeded typed-hop Cypher fast path, all four engines (pandas 29.9->2.46ms 12.1x, polars 13.8->2.28ms 6.1x, cuDF 30.1->4.89ms 6.1x, polars-gpu 25.2->2.49ms 10.1x; native chain pandas 21.1->1.65ms 12.8x, cuDF 23.2->3.84ms 6.0x) - Resident-index covered-shape lookups (pandas 1.74 / polars 1.59 / polars-gpu 1.91 / cudf 5.78 ms) with the 0.58.0 caveat that Polars frames need gfql_index_all(engine='polars') explicitly (fix in PR #1767) - Flat seeded-hop scaling on pandas: 0.159-0.164ms from 0.25M to 32M edges (pandas-only; polars hop not yet index-routed) - One-keyword engine='polars' LDBC SNB SF1 seed-lookup: 1,299.6->106.1ms (12.3x, same build) — replaces the stale "up to ~38x" headline in engines/performance/quick/about/overview/index - LDBC SNB interactive SF1 vs Neo4j 5.26, same box, warm: GFQL wins 4 of 5 clean pairs; Neo4j wins recent-replies (reported as-is) - OLAP multi-join vs embedded Kuzu (100k scale, engine='polars'): q8 5.0ms vs 1,004ms (200x), q9 14.2x — with the honest inverse (Kuzu wins single-table aggregates 2-4x, seeded property-projection lookups 2.4-64x) Prior Orkut/LiveJournal bulk-sweep tables are retained but relabeled as prior-release measurements; methodology disclosures kept and extended with the 0.58.0 tag-sweep provenance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
New docs/source/gfql/indexing.rst: lifecycle guide to GFQL resident indexes (gfql_index_all / gfql_index_edges / create_index / show_indexes / drop_index) — node-id + CSR in/out adjacency sidecars, what engages them on 0.58.0 (seeded typed-hop fast paths incl. property RETURNs and property-seeded lookups per graphistry#1768/graphistry#1770, direct g.hop(); general polars chain traversal honestly noted as not yet covered), the identity + fingerprint staleness contract with safe declines, engine notes (polars needs gfql_index_all(engine='polars') until graphistry#1767), 0.58.0-tag measured numbers, and a runnable end-to-end example (executed). Wired into the GFQL toctree + recommended paths next to index_adjacency; CHANGELOG entry under Development > Documentation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
| return eng | ||
|
|
||
|
|
||
| def _is_resident_index_valid( |
There was a problem hiding this comment.
can we split out autoindexing from enabling polars by default?
Root cause measured, and it makes this PR not independent — needs a decision, not a restackMeasured today on a 3.18M-node / 14M-edge polars graph (LDBC SF1-shaped), same session, rows validated:
An 879× cliff, silent, no diagnostic. That is the cost this PR is aimed at, and it is much larger than "housekeeping." Why it happens
if 'polars' in str(type(g_or_df).__module__):
if isinstance(g_or_df, (pl.DataFrame, pl.LazyFrame)):
return Engine.PANDASSo The problem with landing this in isolationIf the index build alone resolves from the frames (this PR's stated fix — build polars indexes on polars frames), then AUTO queries still resolve to PANDAS (that routing is deliberately on hold, #1743). A pandas query would then find a polars index and decline it — making the default path worse for anyone not explicitly passing So the options are genuinely coupled:
I'd suggest not restacking this as-is until that call is made; option 3 is cheap and safe today. Note on the benchmark laneThe SF1 lane is unaffected: it passes |
Both sides appended tests at the same point in test_index.py; kept both -- the AUTO-preserves-polars-frames class and master's candidate-edge-mask shape pins test different things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
Rebased onto master
|
| suite | result |
|---|---|
graphistry/tests/compute/gfql/index/ |
309 passed |
graphistry/tests/compute/ (whole tree) |
9579 passed, 9 failed, 118 skipped, 19 xfailed |
The 9 failures are pre-existing and not from this branch — pristine master 3f7f79236 was run in the same container and produced the identical 9 (7 × TestCpuOnlyPluginsCudfRoundTrip igraph/cudf round-trips, test_engine_coerces_cudf_to_pandas, test_chain_dask_edges). They are igraph/cudf/dask plugin issues in the image, untouched by this diff.
Diff is unchanged in substance: index/api.py +31/-2 (_resolve_index_engine), tests, CHANGELOG. Still deliberately scoped to the index API — not the global engine=auto routing change in #1743, which is on hold.
Retracting my "ready to merge" — the 2026-07-26 objection above is unaddressed, and it is correctMarking this draft. My previous comment reported a clean rebase and green tests and then called the PR ready. That was wrong: a rebase and a passing suite do not answer the coupling problem raised in this comment, and I skipped straight past it. Restating the objection, because it is the blocker. With this fix,
What I owe before this is reviewable, none of which is done:
The green CI in my last comment stands as a correctness result — 309/309 index tests, and the 9 compute-tree failures proven pre-existing against pristine master in the same container. It was never a perf or default-path result, and I presented it as if adjacent to one. Not restacking or pushing further until (1) is measured. |
Measured: this PR regresses the default path 125–534×, and its win is already reachable on master. It should not land in this form.dgx-spark, The default path (
|
| E | BEFORE | AFTER | |
|---|---|---|---|
| 250K | 0.133 ms (0.129–0.134) | 16.582 ms (15.95–17.06) | 125× slower |
| 1M | 0.131 ms (0.130–0.132) | 32.823 ms (32.59–32.89) | 251× slower |
| 4M | 0.137 ms (0.135–0.137) | 73.007 ms (61.34–75.28) | 534× slower |
Ranges are nowhere near overlapping (BEFORE max 0.137, AFTER min 15.95). AFTER's time equals the un-indexed scan floor measured in the same run (15.8 / 33.2 / 67.8 ms), so this is precisely "the index build is paid for and then declined". It widens with E, because an index-served hop is O(degree) and flat at ~0.13 ms at every size while the scan is O(E).
Inspected, not inferred from timing — via index_trace() and show_indexes():
| arm | BEFORE | AFTER |
|---|---|---|
index_all() + engine='polars' |
declined, index path not applicable -> scan |
served, frontier below cost gate -> index |
index_all() + no engine (default) |
served, frontier below cost gate -> index |
declined, index path not applicable -> scan |
index_all('pandas') + engine='pandas' |
served | served (0.133 → 0.137 ms, TIE) |
Both conditions decline independently: the engine field mismatches (polars index vs pandas query), and _coerce_input_formats converts the whole node+edge frame polars→pandas on every call, which also breaks get_valid's source_ref is df identity check. Result values were byte-identical across all 88 measurements (per size, one signature for edges/nodes/distinct-dst/sum-dst/first-20-ids) — this is purely a performance change, not a correctness one.
And the win does not need this PR
The cell I had not measured, and should have before opening it. gfql_index_all(engine='polars') then hop(engine='polars') — the user simply naming the engine on both sides — on master:
| E | BEFORE (master, explicit) | AFTER (this branch, explicit) | verdict |
|---|---|---|---|
| 250K | 0.154 ms (0.149–0.166) | 0.153 ms (0.147–0.166) | TIE |
| 1M | 0.226 ms (0.219–0.229) | 0.150 ms (0.150–0.173) | AFTER 1.5× |
| 4M | 0.224 ms (0.190–0.501) | 0.213 ms (0.154–0.216) | TIE |
Master already coerces to polars and builds a polars index when the engine is named — create_index does _coerce_input_formats(g, eng) on the resolved engine. So the fast path is already reachable on master, at 0.15–0.23 ms, without this diff. What this PR actually adds is making AUTO guess polars, and that guess is what breaks the default query path.
That reframes the trade: it is not "276× faster vs housekeeping". It is "saves the user from typing engine='polars', at the cost of 125–534× for everyone who types nothing at all." I also could not reproduce the 276× in the PR description on this synthetic uniform-random graph (arm 1 improves 6–15×); that figure came from a differently-shaped dataset and I am not claiming it is wrong, only that it is not what a reader should expect.
What the data does justify — and it is a real, generalizable bug on master today
The two mirror-image cases are the same pre-existing defect, and both are silent:
- master: AUTO index +
engine='polars'query → declined → 2.6 / 4.2 / 8.7 ms instead of 0.15 - this branch: AUTO index + AUTO query → declined → scan floor
Worse, show_indexes() reports valid=True for every index in every cell above, including the ones being declined at query time. A user looking at their own index state is told it is fine while paying a 500× cliff. That is the actionable finding, it needs no policy change, and it fixes both directions rather than moving which caller pays.
Disposition
I am not pursuing this diff. Options 1 and 2 from the earlier comment both stand refuted or blocked: relaxing get_valid's engine equality would not help, because the dominant cost in the regressed arm is the per-call O(E) frame conversion, not the engine field — avoiding that means routing AUTO queries to polars, which is #1743, on hold. So the coupling identified 2026-07-26 is real and not fixable from the index layer.
Proposing instead to close this and replace it with the diagnostic: make an engine-mismatched decline loud (and stop show_indexes() reporting valid=True for an index the resolved query engine cannot use). That is generalizable, cannot regress any path, converts a silent 100–500× cliff into an actionable message, and leaves the eventual fix to #1743 where it belongs. Will open it separately rather than repurposing this branch.
Raw JSON, drivers and aggregators are kept; the arm-5 probe is arm5.py, run interleaved over both clones.
Problem (the C3 "polars hop is linear" mystery — root-caused)
g.gfql_index_all()(engine=auto) on a polars graph silently replaced the polars frames with pandas copies:resolve_engine(AUTO)maps polars input frames toEngine.PANDAS(legacy input-format policy), andcreate_indexcoerces the graph into the resolved engine before building.Consequences for every later
hop(engine='polars')call:_coerce_input_formatsre-converts the FULL nodes+edges frames pandas→polars per call — O(E), ~208ms of a 223ms hop at 4M edges (cProfile:pl.from_pandasnew_stron the 4M-row string column + pandas_isna_string_dtype), ~1.7s at 32M.get_validrequires engine match AND frame identity, and the per-call conversion breaks both — so the hop was also a scan.This is why the C3 ladder showed pandas flat at 0.16ms while polars scaled linearly 16.6→1751ms.
Fix
_resolve_index_engine: when the caller said AUTO and the resident frames are polars, the index API resolves POLARS and indexes the frames in place (the index layer is already engine-polymorphic: numpy sidecar arrays + polars row-gather). Explicit engines are honored unchanged. Deliberately scoped to the index API — this is NOT the globalengine=autorouting-policy change (#1743, on hold).Measured (dgx GB10, container polars 1.35.2, warm median of 30, parity vs pandas indexed oracle at every rung)
Frames verified to remain polars after
gfql_index_all(); parity OK at every rung.Tests
test_index.py::TestIndexAutoPreservesPolarsFrames(file already inPOLARS_TEST_FILES):hop(engine='polars'), parity vs pandas indexed oracleengine='pandas'still coerces (contract unchanged)Full
bin/test-polars.shlocally: failures identical to master baseline (pre-existing local polars-version drift in the cypher conformance matrix; zero new failures). Full index suite (non-GPU) 85/85.🤖 Generated with Claude Code
https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL