Skip to content

perf(gfql): fuse the distinct-domain two-hop count into one polars lazy plan - #1816

Merged
lmeyerov merged 7 commits into
masterfrom
perf/gfql-h3-two-hop-count-lazy
Jul 28, 2026
Merged

perf(gfql): fuse the distinct-domain two-hop count into one polars lazy plan#1816
lmeyerov merged 7 commits into
masterfrom
perf/gfql-h3-two-hop-count-lazy

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

What

_execute_two_hop_count_fast_path (the polars MATCH (a {..})-[{..}]->(b {..})-[{..}]->(c {..}) [WHERE ..] RETURN count(*) lane) chained five eager ops, each its own lazy().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 is not reuse_single_edge_domain, and test_h3_fused_two_hop_count_never_reached_by_equal_domain_shape asserts 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 4 ASTCalls (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-path WHERE a.id < d.id, WHERE b.g IN [...], 1-hop. This is why there is no ordering/null-ordering contract to preserve heretest_h3_two_hop_count_fast_path_has_no_order_by_or_limit_surface pins it rather than inheriting the claim.

Within the served shape the fused lane declines (falls through to the eager code, which answers correctly):

  • equal-domain — by design, see above
  • pandas / cuDF — polars-only
  • non-eager-polars frames (LazyFrame / non-polars) — schema probes on a LazyFrame warn and cost
  • an edge column already named __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 bets

I 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 polars and polars-gpu.

Correctness evidence

  • Differential fuzz: fused vs forced-eager vs pandas oracle over 972 query shapes × 6 graphs = 5,832 comparisons, 4,815 with the fused lane engaged, 0 divergences. Graphs cover parallel edges, self-loops, duplicate node rows, null properties, null endpoints, string ids, empty edge sets, empty node domains.
  • 25 new tests (engine-parametrized pandas / cudf / polars / polars-gpu where cross-engine): engagement, the equal-domain no-regression structural guard, a 9-case fused-vs-eager-vs-pandas differential, empty-match count = 0, both declines, degenerate bindings (node key sharing an endpoint column; source and destination bound to the SAME column), and the ordering-surface proof.
  • Mutation-checked. Killed: in-arm group_by key swap (15 fails), degree-product sumlen (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 restricts b to middle_ids), semiinner on a .unique()'d single-column right side, and innerleft on the final join (null products drop out of sum, then fill_null(0)).
  • Full graphistry/tests/compute suite, 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 skipped from the new tests. bin/lint.sh and bin/typecheck.sh clean.

Perf

dgx-spark, benchmarks/graphbench/matched_q1_q9 at pyg-bench 7e5ce3b (gb_queries.py md5 6e7ae268a5a41742587fcb87854b6e27, verified on the box — query text loaded, never re-typed), RAPIDS image graphistry/test-rapids-official:26.02-gfql-polars, --gpus all --network none, perf lock held, position-balanced K M S S M K × 2 = 12 slots per scale, RUNS=7 WARMUP=3, per-slot medians. Both build trees are frozen git archive exports whose only differing files are gfql_fast_paths.py and the test file.

scale cell master this PR delta overlap Kuzu (same session) verdict
20k polars distinct-domain 16.91 [15.9 16.6 17.2 18.1] 10.02 [9.2 9.9 10.2 10.3] −40.7% no 10.90 [10.82–10.96] LOSE 1.55× → WIN 1.09×, a FLIP (non-overlapping both ways)
20k polars equal-domain 2.44 [2.02–2.46] 2.30 [2.03–2.65] −5.4% yes 3.50 TIE = unchanged by this PR (see #1825 — this warm cell is not one-shot-honest)
100k polars distinct-domain 68.04 [65.9 66.7 69.4 69.7] 37.96 [36.5 37.3 38.6 39.3] −44.2% no 84.73 win 1.25× → 2.23× (widens, not a flip)
100k polars equal-domain 5.46 5.62 +3.0% yes 9.62 TIE = unchanged by this PR (see #1825)

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 — every polars-gpu param and the cudf param actually EXECUTED rather than silently skipping. (On a CPU-only host those 15 polars-gpu params skip with an explicit reason; that is the stated coverage boundary, and this is the receipt that closes it.)

One disclosure: the measured stack tree and this branch differ by exactly one thing — two module-private constants were renamed TWO_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_counts eagerly 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_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 — 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 B slots 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:

scale cell without with delta overlap
20k equal-domain memo-MISS 10.94 [10.24–11.10] 8.25 [7.65–8.71] −24.5% no
100k equal-domain memo-MISS 48.27 [47.65–48.89] 33.13 [32.68–33.66] −31.4% no
20k equal-domain one-shot (fresh Plottable) 12.19 [11.89–13.12] 9.18 [8.30–9.46] −24.7% no
100k equal-domain one-shot (fresh Plottable) 49.67 [48.28–50.71] 34.62 [33.60–35.07] −30.3% no
20k / 100k equal-domain memo-HIT 1.84 / 5.41 2.14 / 5.84 yes → TIE
20k / 100k distinct-domain one-shot 9.35 / 36.68 8.83 / 36.71 yes → TIE
20k / 100k bind_only control 0.02 / 0.02 0.03 / 0.02 yes → TIE

test_h3_equal_domain_two_hop_count_memo_miss_matches_memo_hit pins it: 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.

Re-verified on dgx (RAPIDS 26.02, --gpus all, polars 1.35.2): full graphistry/tests/compute/gfql 6201 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.py in 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 own gb_queries.py text. 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 reaches polars.collect_all with 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 md5 6e7ae268a5a41742587fcb87854b6e27, arms differing by exactly one file):

cell master this PR verdict
20k distinct-domain warm 15.01 [14.80–15.46] 8.87 [8.76–10.77] WIN −40.9%
20k equal-domain memo MISS 10.78 [10.72–11.21] 8.73 [7.68–9.31] WIN −19.0%
100k distinct-domain warm 62.96 [62.79–63.36] 35.77 [34.36–36.19] WIN −43.2%
100k equal-domain memo MISS 47.75 [47.42–47.95] 32.73 [32.46–32.95] WIN −31.5%
memo HIT (20k / 100k) TIE (ranges overlap)

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

  • Merged with master (d0515d34) on 2026-07-28: gfql_fast_paths.py 3-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.
  • Branched off 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_EQ at 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.
  • Typing: master's compute/typing.py has no PolarsFrame/PolarsT (PR refactor(typing): canonical polars frame vocabulary + is_polars_df TypeGuard (Wave 0) #1799 adds them and is unmerged), so this uses polars' own pl.LazyFrame / pl.DataFrame under TYPE_CHECKING, matching the existing fused two-star lane. No Any, no getattr/setattr, no bare list/Dict[str, Any].

lmeyerov and others added 2 commits July 27, 2026 19:59
…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
…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
lmeyerov changed the base branch from master to typing/wave0-polars-frame July 28, 2026 15:11
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
lmeyerov force-pushed the perf/gfql-h3-two-hop-count-lazy branch from d395790 to 315d3cc Compare July 28, 2026 15:56
@lmeyerov
lmeyerov force-pushed the typing/wave0-polars-frame branch from 4538b72 to b94e1f5 Compare July 28, 2026 15:56
…(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
lmeyerov changed the base branch from typing/wave0-polars-frame to master July 28, 2026 16:49
@lmeyerov
lmeyerov merged commit 544a7a7 into master Jul 28, 2026
75 checks passed
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
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