perf(gfql): fuse the distinct-domain two-hop count into one polars lazy plan - #1816
Merged
Conversation
…zy plan The polars two-hop-count fast path (`MATCH (a)-[]->(b)-[]->(c) RETURN count(*)`) chained five EAGER ops, each its own collect, so every intermediate materialized with all edge columns attached -- the 4-semi-join chain built a 199,939-row wide frame the degree join immediately reduced to 120,586. The DISTINCT-domain case (graph-bench q9) now builds the same algebra as ONE lazy plan collected once: same semi-joins, same group_by().len(), same (in*out).sum().fill_null(0).cast(Int64). The EQUAL-domain case (q8) is deliberately NOT routed here -- it keeps the memoized degree-count branch, byte-identical to master -- because q8 is a WIN and trading a cross-call cache hit for a per-call replan would regress it. Declines (falls through to the untouched eager twin, never answers differently): non-eager-polars frames, and an edge column already named like a degree counter. Value identity is the gate: eager-vs-fused-vs-pandas over 972 query shapes x 6 graphs (5,832 comparisons, 4,815 with the fused lane engaged, 0 divergences), plus pinned tests for multiplicity, self-loops, parallel edges, duplicate node rows, empty matches, non-numeric ids and degenerate column bindings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB
lmeyerov
force-pushed
the
perf/gfql-h3-two-hop-count-lazy
branch
from
July 28, 2026 03:01
6d45e8c to
3039915
Compare
…rose, correct the equal-domain claim, widen the memo-MISS lane Conflict resolution - graphistry/compute/gfql_fast_paths.py 3-way merges cleanly (git merge-file rc=0, zero conflict markers) even though both sides add to the file: master added the fused single-hop grouped-aggregate lane at ~1689 and this branch adds the fused two-hop count lane at ~1553 plus the dispatch rewire. - CHANGELOG.md was the only real conflict. Resolved by taking master's file and re-inserting this branch's entry at the top of "### Performance" under "## [Development]" -- not by textually 3-waying the file. Prose: state the SHAPE, not the benchmark - _two_hop_count_fused_polars docstring, the dispatch-site comments, the test-block comments/docstrings and the CHANGELOG entry no longer name graph-bench queries. "distinct-domain" / "equal-domain" is the real shape name and was already there; the qN tags added nothing but a benchmark dependency. Every MEASUREMENT (ms figures, ranges, differential-testing counts) is kept -- those are evidence. Corrected claim: the equal-domain cell is not a one-shot win - The CHANGELOG said the equal-domain cell "stays a win over Kuzu". That is false as written: its speed comes from a cross-call memo that setattrs the degree counts onto the caller's Plottable keyed by id() (#1825), so a warm repeat call is fast while a one-shot query is not. The entry now says only what is true and load bearing -- that cell is UNCHANGED by this PR -- and points at #1825 for why its headline number is not one-shot-honest. This PR neither causes nor fixes #1825. New: the equal-domain MEMO-MISS branch builds one lazy plan - _two_hop_cached_equal_domain_degree_counts eagerly materialized the whole filtered edge frame (every edge column attached) and grouped it twice. The miss branch now expresses both degree arms as one lazy plan collected via collect_all over a shared filtered-edge sub-plan, so polars pushes the src/dst projection into the semi-joins. Same algebra, same values. The memo HIT returns before reaching it, so a warm call is untouched -- which the measurement confirms rather than assumes. - Measured on dgx-spark under the perf lock, position-balanced A B B A B A A B slots, per-slot medians (never best-of), 21 runs + 5 warmup per slot, arms differing by exactly this one file, rows and values identical on every arm: memo-MISS 20k 10.94 [10.24-11.10] -> 8.25 [7.65-8.71] ms -24.5% no overlap memo-MISS 100k 48.27 [47.65-48.89] -> 33.13 [32.68-33.66] ms -31.4% no overlap one-shot 20k 12.19 -> 9.18 ms; one-shot 100k 49.67 -> 34.62 ms memo-HIT, distinct-domain and a bind_only control: TIE at both scales - test_h3_equal_domain_two_hop_count_memo_miss_matches_memo_hit pins the miss branch: memo engagement is asserted via the memo attribute (absent before, one entry after), and the cold answer, the warm answer and a never-warmed Plottable's answer must all equal the pandas oracle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
#1828 landed the low-cardinality count(*) gate in gfql_fast_paths.py, which is the same file this branch adds its fused two-hop count lane to, so this needed a real 3-way rather than the CHANGELOG-only resolution the earlier merges took. git merge-file returns rc=0 with zero conflict markers, and the arithmetic checks out: 2687 merged lines = 2538 (branch) + 149 (master-added), so nothing was dropped. Structurally the two additions are disjoint -- this branch dispatches the two-hop lane from _execute_two_hop_count_fast_path, while #1828 gates a SINGLE-hop grouped count on _LOWCARD_COUNT_MAX_GROUPS / _MAX_INPUT_ROWS. They do not gate on the same shape. A clean textual merge is not on its own proof of semantic compatibility -- the sibling PR #1819 hit exactly that trap, where a marker-free merge silently put a new fused lane in front of a guarded twin. Flagging it here so review checks the interaction rather than trusting rc=0; CI is the arbiter. CHANGELOG resolved structurally: master file taken whole, this branch entry re-inserted at the top of the same section it occupied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
lmeyerov
added a commit
that referenced
this pull request
Jul 28, 2026
Docs sit at the TOP of the stack so a change-request here cannot block the code PRs underneath it. CHANGELOG hand-resolved: this branch REPLACES #1823's entry rather than adding one, so neither a textual 3-way nor the add-only fallback applies. Built as the parent file with the q8 withdrawal applied in place, asserting the anchor occurs exactly once before the edit and the entry exactly once after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
lmeyerov
force-pushed
the
perf/gfql-h3-two-hop-count-lazy
branch
from
July 28, 2026 15:56
d395790 to
315d3cc
Compare
lmeyerov
force-pushed
the
typing/wave0-polars-frame
branch
from
July 28, 2026 15:56
4538b72 to
b94e1f5
Compare
…(linear PR stack) Stacked so the shared CHANGELOG stops conflicting on every landing: this branch now sits on top of typing/wave0-polars-frame rather than beside it. Files touched by both were 3-way merged with zero conflict markers; CHANGELOG resolved structurally, with this branch's entry re-inserted into the parent's file under the same section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
lmeyerov
added a commit
that referenced
this pull request
Jul 28, 2026
Docs sits at the TOP so a change-request here cannot block the code underneath. CHANGELOG hand-resolved: this branch REPLACES #1823's entry rather than adding one, so neither a textual 3-way nor the add-only fallback applies. Built as the parent file with the q8 withdrawal applied in place, asserting the anchor occurs exactly once before the edit and the entry exactly once after. Rebuilt from this branch's clean pre-stack head: an earlier stacking attempt uploaded child blobs over already-merged ones, which propagated a dropped POLARS_TEST_FILES entry up the whole stack and turned test-gfql-core red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
The stacking upload rewrote the tree entry as mode 100644, so the test-polars lanes died with 'Permission denied' (exit 126) before running a single test.
lmeyerov
added a commit
that referenced
this pull request
Jul 29, 2026
…giene baseline The per-file ratchet in bin/ci_type_hygiene_baseline.json is a SNAPSHOT, so it goes stale whenever a PR other than the one that owns it touches a baselined file. #1830 captured its baseline on a branch whose merge-base is b6181d3 -- before #1800, #1799 and #1816 landed -- and each of those three added cast() calls to a file #1830 had already pinned. All four were green on their own bases; the merged combination was not. The baseline is UNCHANGED. The findings are removed instead: - 3 of the 7 were never typing.cast. The guard matches any call named `cast`, including the attribute form, so pl.Expr.cast -- a polars RUNTIME dtype conversion -- counts as a typing finding. Those carry the documented `# hygiene-ok: explicit-cast` escape hatch with a reason. - The other 4 are real and are gone by DECLARATION rather than by call-site assertion. _two_hop_cached_equal_domain_degree_counts declares `counts: Tuple[DataFrameT, DataFrameT]` once, collapsing four casts into one localized `# type: ignore[assignment]` on the polars arm. _apply_connected_optional_match declares `seed_ids: SeriesT` / `node_ids: SeriesT`, since selecting one column off a frame is a Series on every engine. All three files now sit at or below baseline (18/18, 132/133, 41/42). Typing-only: typing.cast is the identity function at runtime, so every removal is provably value-preserving. The one restructured line binds an unchanged pl.Expr list to a name so the per-line suppression fits the 127-column limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
_execute_two_hop_count_fast_path(the polarsMATCH (a {..})-[{..}]->(b {..})-[{..}]->(c {..}) [WHERE ..] RETURN count(*)lane) chained five eager ops, each its ownlazy().collect(). Every intermediate materialized in full with all edge columns still attached, and nothing could push across an op boundary — profiled on the 20k graph-benchmark dataset the four-semi-join chain built a 199,939-row wide frame that the degree join immediately reduced to 120,586, with 88–96% of that query's wall time inside those collects.The distinct-domain case now builds the same algebra as one lazy plan, collected once. New helper
_two_hop_count_fused_polars; the eager code is untouched and remains the fallback.The EQUAL-domain shape is not routed through the fused lane — deliberately
The equal-domain shape (equal node domains + equal edge match) takes a memoized degree-count branch (
_two_hop_cached_equal_domain_degree_counts, cached on the caller's Plottable across calls). Routing it through the fused lane would trade a cross-call cache hit for a per-call replan. The gate isnot reuse_single_edge_domain, andtest_h3_fused_two_hop_count_never_reached_by_equal_domain_shapeasserts the fused lane is not even called for that shape. What this PR does change for that shape is its MEMO-MISS branch — see "Equal-domain memo-MISS" below.Shape enumeration
The outer gate (
_two_hop_count_alias+_two_hop_count_binding_ops) is narrow: exactly 4ASTCalls (rows(table='nodes', binding_ops=[n,e,n,e,n])→with_([("__cypher_group__",1)])→group_by(["__cypher_group__"], [(alias,"count")])→select([(alias,alias)])) with both edges_is_connected_fast_single_hop. Probed end-to-end, these 13 shapes never reach either lane:ORDER BY,LIMIT,ORDER BY … LIMIT,count(a),count(DISTINCT a), a second RETURN item,-[]-,<-[]-,-[*1..2]->,-[r]->, 3-hop, same-pathWHERE a.id < d.id,WHERE b.g IN [...], 1-hop. This is why there is no ordering/null-ordering contract to preserve here —test_h3_two_hop_count_fast_path_has_no_order_by_or_limit_surfacepins it rather than inheriting the claim.Within the served shape the fused lane declines (falls through to the eager code, which answers correctly):
__in_count__/__out_count__— defensive; measured identical on polars 1.42, but name survival under projection pushdown is version-dependent and the eager twin is correct, so it hands back rather than betsI did not find a shape the fused plan answers wrongly; both declines outside the equal-domain gate are conservative boundaries, not known-wrong cases.
Not a GPU change: like the eager code and the existing fused two-star lane, it collects on CPU polars for both
polarsandpolars-gpu.Correctness evidence
pandas/cudf/polars/polars-gpuwhere cross-engine): engagement, the equal-domain no-regression structural guard, a 9-case fused-vs-eager-vs-pandas differential, empty-matchcount = 0, both declines, degenerate bindings (node key sharing an endpoint column; source and destination bound to the SAME column), and the ordering-surface proof.group_bykey swap (15 fails), degree-productsum→len(11), start-domain→end-domain (8), end-domain→middle-domain (7), product→sum (10), dedup parallel edges (5), out-arm edge frame swap (3), removing either decline (1 each), routing the equal-domain shape into the lane (2). Three mutants survived and are provably equivalent rewrites, not coverage gaps: dropping the middle semi-join on the out arm (the final inner join already restrictsbtomiddle_ids),semi→inneron a.unique()'d single-column right side, andinner→lefton the final join (nullproducts drop out ofsum, thenfill_null(0)).graphistry/tests/computesuite, master arm vs this branch: failure sets are byte-identical (123 pre-existing failures, all local GPU-env artifacts reproduced on unmodified master),+25 passed / +15 skippedfrom the new tests.bin/lint.shandbin/typecheck.shclean.Perf
dgx-spark,
benchmarks/graphbench/matched_q1_q9at pyg-bench7e5ce3b(gb_queries.pymd56e7ae268a5a41742587fcb87854b6e27, verified on the box — query text loaded, never re-typed), RAPIDS imagegraphistry/test-rapids-official:26.02-gfql-polars,--gpus all --network none, perf lock held, position-balancedK M S S M K× 2 = 12 slots per scale, RUNS=7 WARMUP=3, per-slot medians. Both build trees are frozengit archiveexports whose only differing files aregfql_fast_paths.pyand the test file.Baseline reconciliation (the check that makes the deltas transferable): the pandas arm, which this change cannot touch, reproduces the reference 20k board to +0.3% … +4.1% on all nine cells, and same-session Kuzu's distinct-domain cell lands at 10.90 ms vs the board's 10.93 — the harness matches. polars runs uniformly ~12–33% high against the board on this box (a loaded-host thread-pool offset both arms share), which is why the same-session distinct-domain win is 1.09× rather than the 1.30× a ratio-transfer onto the board's own polars baseline would give. The measured, same-session number is the one quoted.
All 18 (engine, query) cells at each scale: 0 row-count and 0 canonical-value divergences between arms.
Flag isolation (the lane's own rule: re-run a flagged sub-20 ms cell at high repetition before believing it). The 12-slot 100k screen produced one non-overlapping cell besides the distinct-domain one — an unrelated single-hop grouped-aggregate cell at −12.1% faster, on a query the fused lane is never even called for. Re-run in isolation, 8 slots, RUNS=31: that cell went −12.1% → +6.1%, ranges overlap = TIE (sign flipped — noise, exactly as the lane's README documents for a ~13 ms cell). Same run: equal-domain +3.0% → +3.8%, TIE, distinct-domain −44.2% → −44.8%, still non-overlapping (master 63.9–65.2, this PR 35.3–36.4).
GPU receipt — dgx-spark (NVIDIA GB10), image
graphistry/test-rapids-official:26.02-gfql-polars,--gpus all, polars 1.35.2 / cudf 26.02.01 / cudf_polars 26.02.01, GPU collect probe green: the new tests run 40 passed, 0 skipped — everypolars-gpuparam and thecudfparam actually EXECUTED rather than silently skipping. (On a CPU-only host those 15polars-gpuparams skip with an explicit reason; that is the stated coverage boundary, and this is the receipt that closes it.)One disclosure: the measured
stacktree and this branch differ by exactly one thing — two module-private constants were renamedTWO_HOP_*→_TWO_HOP_*(5 sites, identical string values) after the lane was already running. Nothing else differs.Equal-domain memo-MISS: one lazy plan (added 2026-07-28)
_two_hop_cached_equal_domain_degree_countseagerly materialized the whole filtered edge frame (every edge column still attached) and grouped it twice. Its MISS branch now builds both degree arms as ONE lazy plan (pl.collect_allover a shared filtered-edge sub-plan), so polars pushes the src/dst projection into the semi-joins. Same algebra, same values. The memo HIT returns before reaching it, so a warm call is untouched — measured, not assumed.This is the branch a one-shot query, a rebound
Plottable, or any future removal of the cross-call memo (#1825) actually runs.dgx-spark, perf lock held, position-balanced
A B B A B A A Bslots per scale, per-slot medians, RUNS=21 WARMUP=5, arms differing by exactly this one file (md5s recorded in-container), rows and values identical on every arm:Plottable)Plottable)bind_onlycontroltest_h3_equal_domain_two_hop_count_memo_miss_matches_memo_hitpins it: memo engagement is asserted via the memo attribute (absent before, one entry after), and the cold answer, the warm answer and a never-warmedPlottable's answer must all equal the pandas oracle.Re-verified on dgx (RAPIDS 26.02,
--gpus all, polars 1.35.2): fullgraphistry/tests/compute/gfql6201 passed, 25 skipped, 19 xfailed, 0 failed; ruff clean; mypy over the whole package adds 0 new errors vs the same tree without this change.Lock-in
tests/test_gfql_two_hop_fused_engagement.pyin pyg-bench (self-merged there): a structural probe, not a timing gate — a growth-ratio or wall-clock gate polices the wrong direction for a constant-factor plan fusion and would keep passing after the lane silently stopped engaging. It asserts the distinct-domain shape is served by the fused lane, the equal-domain shape never enters it, and fused == eager == pandas — on the board's owngb_queries.pytext. Verified to fail loudly (3 tests) against a pre-change build.Correction (2026-07-28): that pre-existing probe covers fused-lane ENGAGEMENT only — it does not cover the memo-MISS widening described above, as an earlier revision of this section implied. A second lock-in now does:
tests/test_gfql_two_hop_memo_miss_lazy.py(pyg-bench #118, merged) asserts that a memo MISS reachespolars.collect_allwith both degree arms in one plan, and that a memo HIT never reaches it at all. 4 pass on this build; 1 fails against a pre-change build.Independently re-measured under pyg-bench's own harness (dgx-spark, all three perf locks held, box verified quiet, slots
A B B A B A A B, RUNS=21 WARMUP=5, per-slot medians, board query text md56e7ae268a5a41742587fcb87854b6e27, arms differing by exactly one file):Two things stated rather than smoothed over: the deltas replicate but the absolute centers run ~10% below the numbers carried forward in this PR (a quieter box — so the ratio, not the center, is the transferable figure); and although the 20k memo-HIT cell is a TIE by the overlap rule, all four PR slots (1.58–2.40) sit above three of four master slots (1.22–1.40). Worth watching, not a loss. The 100k memo-HIT is clean noise.
Notes for review
master(d0515d34) on 2026-07-28:gfql_fast_paths.py3-way merged cleanly (rc=0, no markers) against master's fused single-hop grouped-aggregate lane; CHANGELOG.md was the only real conflict and was resolved by taking master's file and re-inserting this entry under### Performance.master(233b64c8). PR perf(gfql): constant-fold literal-only sub-expressions at plan time (generalizes the toLower residual fix) #1800 is open against the same file but touches_RESIDUAL_TOLOWER_EQat line ~617; this PR only adds a new function at ~1549 and rewires the dispatch at ~1997. The diffs are mechanically separate and either can rebase over the other.compute/typing.pyhas noPolarsFrame/PolarsT(PR refactor(typing): canonical polars frame vocabulary + is_polars_df TypeGuard (Wave 0) #1799 adds them and is unmerged), so this uses polars' ownpl.LazyFrame/pl.DataFrameunderTYPE_CHECKING, matching the existing fused two-star lane. NoAny, nogetattr/setattr, no barelist/Dict[str, Any].