perf(gfql): kill per-call O(E) terms in seeded Cypher/chain queries (#1755 slice 1)#1757
perf(gfql): kill per-call O(E) terms in seeded Cypher/chain queries (#1755 slice 1)#1757lmeyerov wants to merge 2 commits into
Conversation
…gmentation caches) IS1 profiling found two linear-with-E costs per seeded Cypher call on a resident graph: the index path's simple-equality keep-mask rebuilt via a full column compare (eq_str), and the polars chain's per-call with_row_index O(E) copy whose fresh identity also defeated downstream id-keyed caches. Both now recycle-safe caches keyed on the resident frame (weakref.finalize eviction, the _pl_nan_to_null pattern). Profiled: both terms eliminated; 274 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
… typed-edge slice) White-box identity/eviction/decline-branch tests for the three recycle-safe per-frame caches in #1755, plus an end-to-end run-twice parity guard proving the caches never serve a stale/corrupt frame across repeated seeded queries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
A/B finding (honest, pre-merge): slice 1's caches don't engage on a naive seeded queryRan a same-container baseline(master)-vs-this-branch A/B (Message→Person HAS_CREATOR, seeded 1-hop typed cypher, warm median, 4 engines, at 200k and 4M edges). Results were parity-identical (result_rows=1 everywhere; no corruption) but showed no speedup (4M: pandas 496→518, polars 111→114, cudf 79→83, polars-gpu 95→95 ms). Root-caused locally (not inferred): after repeated polars Implication: this PR is a correctness-verified, typed, green refactor that removes O(E) terms on the index path, but the headline points-flip is not yet demonstrated. Before merge I'll redo the A/B on the resident-#1658-index LDBC IS1/IS5 harness (where #1738 measured the 67–79% cut), confirm cache engagement, and determine whether slice 1 alone suffices or slice 2 ( |
DEFINITIVE A/B via
|
| metric | baseline | fixed (1+2) |
|---|---|---|
| IS5 bare_hop (native ceiling) | 0.131ms | 0.133ms |
| IS5 native_chain_notype | 13.0 | 15.3 |
| IS5 native_chain_typed | 21.1 | 23.0 |
| IS5 cypher | 30.1 | 31.6 |
| IS5 tax (cypher/bare_hop) | 229× | 238× |
Fixed is identical-to-slightly-slower. The decomposition shows the tax is bare_hop 0.13ms → +13ms chain machinery (combine_steps two-pass re-merge) → +8ms full-column type filters → +9ms cypher projection — and slices 1/2 touch none of those. #1738's "slices 1-2 cut points 67-79%" does not reproduce on its own probe.
Disposition: this PR is confirmed correctness-safe perf-hygiene (removes O(E) eq_str/with_row_index terms on the index path) but is not the seeded-points lever. Recovering the tax needs a combine_steps lean-path redesign (clean-authored, not a #1738 cherry-pick). Leaving #1757 open as optional hygiene — not to be merged as 'the tax fix'.
Restacked and measured (locked-step 5 report)Restacked onto master cleanly. SF1 polars, both reps reported, perf lock held,
The four queries that move are exactly the four cells we lose to Kuzu. The four that don't move are exactly the ones we win. Non-targets sit at 0.93–1.06x while targets move 1.12–1.39x consistently across both reps — so this is signal, not host noise, and it confirms the PR is aimed at the right layer (per-call overhead, #1755). It does not flip any cell: 74.5 ms against Kuzu's 1.35 ms is a 55x loss, down from 77x. Blocked on three cache properties, not rejectedThe caches are three module-level dicts holding data-plane objects (an O(E) boolean mask, a full Empirical follow-up (polars 1.35.2, python 3.13.12) says every objection has a cheap remedy:
A check-then-act race probe (8 threads, 1 key) produced 1 build, but that is timing under the GIL, not a guarantee — and the threading result above shows real parallelism is available. Weak evidence; the structural argument stands. Measured memory, stated precisely25 live indexed frames took RSS 443 → 998 MB (22.2 MB/frame), but retained cache entries read 0 at inspection — so that growth is the adjacency indexes, not this PR's caches. The unbounded-growth concern is therefore structural (no cap, no lock, Suggested next revision: UUID token key + explicit lock + bounded size (or registry-scoped lifetime), then re-measure. The speedup above is real and on-target; only the cache mechanics are in the way. |
Evidence from today that bears directly on this PRThis PR's goal is killing per-call O(E) terms in seeded Cypher/chain queries, using caches. Today I found and fixed one such term outright, with no cache and no global state — worth weighing before this lands. #1782 ( Measured on the real LDBC SF1 polars lane, position-balanced ABBA/BAAB with rows validated: IS1 −29.1%, IS5 −25.4%, IS2 −19.0%, IS4 −10.5%, IS7 −2.3%. 4 faster, 0 slower. Where the remaining IS1/IS5 cost actually is (measured today, correctly-configured polars: polars-engine indexes +
So the residue is the polars executor building and collecting a combine plan over the full 3.18M-node / 14M-edge frames regardless of how narrow the traversal already is. That is not a repeated-work problem a cache would fix — it is one collect over graph-sized inputs per query. Caching it would memoize a graph-sized intermediate, which is the memory-blowup risk already raised on this PR. Suggestion: re-measure this PR on top of #1782 before deciding. If the O(E) term it was chasing is the one #1782 already removed, this can close as a quarry with the analysis, and the remaining work is making the polars combine proportional to the traversal result — a planner change, not a cache. I have not measured this PR myself (speedup / peak_rss / repeated-query growth loop all still outstanding), so this is input to that decision, not a verdict on it. |
|
Closing with the analysis, per the "restack on the current stack and measure; no movement → I restacked it on top of #1784 ( Term 1 — typed keep-mask cache: the target function no longer existsThis PR adds #1782 did not memoize that compare — it removed it. The seeded index path now evaluates the (#1782 does retain a whole-column mask, but only as a cost guard for fixed-point walks that Term 2 — augmented-frame cache: real, but now ~1 msThe Why that is not worth the priceThe remaining 4.6% would be bought with a module-level mutable data-plane cache keyed on What survives from itThe insight was right and it did land, just not as a cache. The two things this PR identified — Reopen if a future profile shows the augmentation term growing back into something worth a |
…peline.py Pure code move, no behaviour change. row_pipeline.py 1638 -> 1528 lines; the unbounded/variable-length arms live in polars/varlen_rows.py with a docstring explaining the exhaustion-depth walk and why deduping by endpoint is sound. Motivation is rebase surface: #1757, #1714 and the seeded-chain work all touch row_pipeline.py, and ~270 lines of var-length specialization sitting in the core flow made every one of those a conflict. RowPipelineMixin stays imported FUNCTION-LOCALLY exactly as at the original site — hoisting it to module scope reintroduces an import cycle (row.pipeline -> lazy engine -> back), so the move preserves it and says why. 2244 polars tests pass, ruff clean.
…ops) — unblocks LDBC IS6 on polars (#1709) (#1781) * feat(gfql): native polars unbounded directed var-length binding rows (LDBC IS6, #1709) The Cypher multi-alias bindings table (`rows(binding_ops=...)`) lowered natively for fixed-length and BOUNDED variable-length segments, but an unbounded fixed-point segment (`-[*]->` / `-[*0..]->`) declined with "polars engine does not yet natively support cypher row op 'rows'". That was the last shape blocking engine='polars' on LDBC SNB interactive-short-6, the one interactive-short query polars could not answer. Unbounded DIRECTED fixed point now lowers natively. Termination is data-dependent, so instead of pandas' expand-paths-until-empty (which materializes every partial path at every hop) the lowering runs a dedup-by-node frontier walk to find the exhaustion depth D, then reuses the SAME lazy bounded pair-join loop the `-[*1..k]->` arm uses with max_hops=D. Deduping by endpoint changes no walk's EXISTENCE, only its multiplicity, which the bounded loop then reproduces in full -- so the emitted rows are pandas-identical while the exponential path expansion happens once, lazily. A cycle reachable from the seed means infinitely many paths: that raises the same E108 "require terminating variable-length segments" error pandas raises, detected by a pigeonhole bound on the distinct nodes rather than after the blow-up. Also fixes a latent silent-wrong found by the differential fuzz: the polars builder rebuilt bindings from the CHAIN OUTPUT while pandas rebuilds from the pre-chain base graph. The traversal prunes to what IT considers matched, which is not the bindings builder's match set -- a zero-hop var-length segment binds a seed with no matching outgoing edge, and the traversal drops that node. So `-[*0..k]->` could silently return fewer rows than pandas. The builder now sources its node/edge tables from the same base graph pandas uses (and that the indexed builder was already handed). Honest declines unchanged in spirit (NIE, never a silent answer): undirected unbounded, aliased var-length relationships, and unbounded segments without to_fixed_point (pandas truncates at a bound this lowering cannot reconstruct). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxxTFpKiqA4FQa9Pjy8yR1 * review: share the bounded var-length expansion; type the polars path bag lazily - extract `_directed_varlen_reachable_polars` so the unbounded fixed-point arm and the bounded `-[*1..k]->` arm run literally the same loop instead of two copies kept in sync by a comment. - pin the generic bindings builder's path bag as a LazyFrame. It always was one; mypy inferred eager because `filter_by_dict_polars` declared the eager type. - make `filter_by_dict_polars` frame-polymorphic via a constrained TypeVar (DataFrame | LazyFrame) rather than declaring one flavour and being called with both -- the eager viz lane and the lazy chain lane take the same `.filter(expr)` path, and the TypeVar keeps the caller's flavour on the way out. Removes the mypy noise this file was carrying, so the new code lands at delta 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxxTFpKiqA4FQa9Pjy8yR1 * review: keep the localized ignores matching the new error code Widening filter_by_dict_polars to a constrained TypeVar changes the diagnostic at its two engine-neutral DataFrameT call sites from arg-type to type-var, so the existing localized ignore no longer applied. Retarget it and add the twin in gfql_fast_paths. Both sites are already gated on a polars engine; the cast is what the surrounding code has always asserted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxxTFpKiqA4FQa9Pjy8yR1 * refactor(gfql): move the var-length row specializations out of row_pipeline.py Pure code move, no behaviour change. row_pipeline.py 1638 -> 1528 lines; the unbounded/variable-length arms live in polars/varlen_rows.py with a docstring explaining the exhaustion-depth walk and why deduping by endpoint is sound. Motivation is rebase surface: #1757, #1714 and the seeded-chain work all touch row_pipeline.py, and ~270 lines of var-length specialization sitting in the core flow made every one of those a conflict. RowPipelineMixin stays imported FUNCTION-LOCALLY exactly as at the original site — hoisting it to module scope reintroduces an import cycle (row.pipeline -> lazy engine -> back), so the move preserves it and says why. 2244 polars tests pass, ruff clean. * review(#1781): decline `-[*k..]->` for k>=2, and fix the coverage baseline Adversarial review of this branch found a silent-wrong regression and a red CI. BLOCKER — `-[*k..]->` with k >= 2 returned WRONG COUNTS, no error. The unbounded-directed arm of the `rows` gate had no `min_hops` guard. Cypher `-[*k..]->` lowers to `{hops: null, min_hops: k, to_fixed_point: true, direction: forward}`, so every k >= 2 was SERVED. Pandas' `step_pairs` come from the var-length `edge_op.execute` hop, which empties when its `max_reached_hop < min_hops` and otherwise drops edges labelled below min_hops; `max_reached_hop` is a dedup-by-node BFS eccentricity, not a longest-walk length, so the raw-edge reconstruction here expands a different edge multiset. On a 7-node acyclic graph, both engines answering: MATCH (a)-[*3..]->(b) RETURN count(*) pandas 0 polars 30 MATCH (a {id: 0})-[*2..]->(b) RETURN count(*) pandas 24 polars 41 85 of 1200 acyclic fuzz cases diverged, with zero declines. Master declines all of them. The second gate this PR's description relies on (`_is_native_multihop` in `_chain_traversal_polars`) never runs here: `RETURN count(*)` lowers to a pure-CALL chain, so the `rows` gate is the only gate. The existing decline test missed it because it pins `to_fixed_point=False` — a shape Cypher never emits. Now the unbounded arm requires resolved `min_hops <= 1`, mirroring the undirected arm; 0 and 1 (`-[*]->`, `-[*0..]->`, the IS6 walk) stay served. BLOCKER — CI was red and the coverage gate never ran. `varlen_rows.py` was missing from `coverage_baselines/ci-polars-py3.12.json`, whose own note requires a complete list, so `test-polars (3.12)` exited 3 and `changed-line-coverage` (which needs it) was SKIPPED — this branch's changed lines were never gated. Also fixed: * the cycle path raised a different exception CLASS and `.code` per engine (pandas GFQLTypeError/E303 via execute_call's wrapper, polars a raw GFQLValidationError/E108 because the native kernel runs before that wrapper). Repo control flow keys on `.code`. Now wrapped identically. * cycle detection was bounded by the GLOBAL node count with an eager collect per hop, so a two-node cycle reachable from one seed cost O(graph) collects (measured linear in global N) before raising. Now bounded by the REACHABLE set: a walk of h edges visits h+1 nodes, all within `seen`, so h >= |seen| IS a repeat. Exact in both directions, and the O(E) `node_cap` scan it replaces is gone entirely (`pairs_df.height == 0` is the same test). * the "hoisting reintroduces an import cycle" note was FALSE — disproven by hoisting. The sibling import moves to the import block and the mid-file `# noqa: E402` goes; `RowPipelineMixin` stays function-local, matching the engine convention. Unused typing imports and a stale line count removed. * `to_fixed_point` WITH an explicit bound was newly served and undocumented. It is parity-correct (pandas ignores the flag once max_hops is set) but was pinned by nothing; now tested and in the changelog. Tests, all mutation-checked (reverting each fix fails the matching test): * gate-level, k in 0..3, either side of the boundary * end-to-end through Cypher on the repro graph — the one that actually catches the blocker, since both engines answer and only values differ * the cycle test now asserts class and `.code` are ENGINE-INDEPENDENT rather than pinning polars' own code (the old form passed while they disagreed) * a long acyclic walk must not be mistaken for a cycle, and a small reachable cycle behind a large unreachable remainder must still raise — the two failure modes of the new bound Verified in-container on dgx-spark: 86 passed in the binding-rows file, 276 with the row-pipeline file, and `graphistry/tests/compute` with `--gpus all` gives an IDENTICAL 9-failure set to this branch's base (pre-existing dask/cuDF coercion), 6821 passed vs the base's 6804. mypy differential: 166 errors on both trees, so these changes add none. ruff clean under the repo config. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB * review(#1781): decline to_fixed_point with an explicit bound The re-review of the previous commit found that the gate rewrite which closed the unbounded `-[*k..]->` hole left a narrower one open, and that the test I added to pin it could not have caught it. Master declined on `bool(op.to_fixed_point)` ALONE. Restructuring the gate to key on the RESOLVED MAX incidentally let `to_fixed_point=True` combined with an explicit bound fall through to the bounded arm — where it hits the same reconstruction gap as the unbounded case, silently, for min_hops >= 3: fwd min=3 max=4 tfp=True pandas 0 polars 30 (master: NIE) fwd min=3 max=5 tfp=True pandas 0 polars 30 fwd min=4 max=5 tfp=True pandas 0 polars 6 fwd hops=3 tfp=True pandas 0 polars 24 reverse spellings: identical Not reachable from Cypher — `cypher/parser.py` sets to_fixed_point False for `*k` and `*i..k`, and only leaves it True for `*` and `*k..`, which always have max_hops None. So this is the AST / `rows(binding_ops=...)` wire surface only. Hard to reach is not correct, and the previous commit additionally claimed in the CHANGELOG that the shape "matches pandas", which is false. Declining it restores master's behaviour exactly, so it cannot regress anything that previously worked. WHY THE OLD TEST WAS BLIND, since the same mistake is easy to repeat: it compared flagged-vs-unflagged WITHIN each engine and its parametrization stopped at min_hops=2. A within-engine comparison never consults the pandas oracle, so it cannot see a divergence where BOTH engines answer; and min_hops <= 2 happens to agree on that fixture. It now asserts the DECLINE, runs to min_hops=4, and is joined by a value test on the divergence graph that compares ROW COUNTS against the oracle (engine-neutral: a bare `rows()` call emits engine-specific scaffolding columns on every shape, pre-existing and unrelated). Mutation-checked: removing the guard fails 9 tests, including the value test — the property the previous version lacked. Verified in-container on dgx-spark: binding-rows file 90 passed; `graphistry/tests/compute` with `--gpus all` gives 6825 passed and a failure set byte-identical to this branch's base (the 9 pre-existing dask/cuDF coercion failures). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
Extracts the foundational slice of the seeded-Cypher abstraction-tax fix (#1755) as ONE clean, review-gated PR off master — the [EXPERIMENTAL] prototype #1738's
ee510f78, isolated from the still-in-design "lean gather-combine" slices (those never land as-is).Eliminates the two per-call O(E) terms that made
g.gfql("MATCH…")seeded point/short-query latency GROW with edge count (LDBC SF0.1→SF1 grew 4–5× on 11× data while Kuzu stays flat), even with the #1658 CSR index resident:gfql/index/traverse.py) — the feat(gfql): physical adjacency indexes for O(degree) seeded traversal #1658 index path rebuilt its simple-equality edge keep-mask via a full column compare (eq_str, ~2.8ms/1M rows) on every seeded hop. Now cached per(id(frame), engine, scalar edge_match).lazy/engine/polars/chain.py) — the polars chain rebuiltwith_row_indexper call: an O(E) copy whose fresh object identity also defeated every downstream id-keyed cache (including Replace NaNs with nulls since node cannot parse JSON with NaNs #1). Now one augmentation per resident frame; stable identity restored (which is what lets cache Replace NaNs with nulls since node cannot parse JSON with NaNs #1 actually hit).lazy/engine/polars/row_pipeline.py) — the bindings-row path re-filtered the full edge type column O(E) per call; now a cached eager slice per(id(frame), scalar edge_match).All three use the merged #1735 clean-cache pattern (
weakref.finalizeon the source frame → a recycledid()can never serve a stale entry; a rebound/new frame re-computes). Non-scalar / predicate / membership / null edge-match forms decline to the general per-call path (never mis-cache).Why this is safe
g.hop()already hits sub-ms via feat(gfql): physical adjacency indexes for O(degree) seeded traversal #1658 (0.20ms floor). This is purely the compile+chain-executor abstraction tax (plan criterion 5(c)).Tests
New
TestSeededChainTaxCaches(intest_engine_polars_chain.py, already in the polars lane): cache-identity +weakrefeviction-on-GC + every decline branch for each of the three caches, plus an end-to-end run-twice parity guard (repeated seeded typed query is stable + native + matches the pandas oracle). Local: 6/6 new + 1009 chain-parity + 82 non-GPU index-parity pass; mypy clean on all 3 files.Pending before merge
.agents/skills/reviewpass.Extracted from #1738 (
ee510f78); base stack #1734/#1735/#1736/#1737 all merged. Fixes part of #1755.🤖 Generated with Claude Code