perf(gfql): gate a low-cardinality pure count(*) onto value_counts, and keep q1 off it - #1828
Merged
Conversation
…rossover curve
THRESHOLDS FIRST, VALIDATION SECOND. This commit fixes the two numbers the gate
routes on, from the interleaved crossover sweeps alone. No verdict lane, no
q1-q9 run, no differential and no mutation test has been executed against the
gate at this point, because a threshold chosen after seeing the verdicts is
unfalsifiable.
_LOWCARD_COUNT_MAX_GROUPS = 32
_LOWCARD_COUNT_MAX_INPUT_ROWS = 100_000
Measured on dgx-spark under the exclusive perf lock, RAPIDS image
26.02-gfql-polars, polars 1.35.2, 20 threads, interleaved A,B,B,A per round,
15 rounds x 3 reps = 90 samples/arm/cell, 214 cells, ZERO value mismatches.
The curve is two-dimensional and neither bound alone is sound:
* group_by(maintain_order=True).agg(pl.len()) carries a FLAT ~2 ms
coordination cost present ONLY at low group cardinality, gone between 32
and 64 groups (int keys, 20,000 rows: 32 groups 2.054 ms -> 48 groups
0.411 -> 64 groups 0.291);
* value_counts has no such cost but scales WORSE with input rows -- at
1,000,000 rows it loses even at 2 groups (4.137 ms -> 8.591 ms).
Cardinality 32 is the largest whose conservative worst case (group_by p25 vs
value_counts p75) still favours value_counts in every dtype x row-count cell;
48 groups already fails at 0.64x. 100,000 rows is the largest measured input
where that holds for every cardinality <= 32; 150,000 fails at 0.80x on string
keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
… fixture Three findings from validating the gate, all recorded rather than absorbed. 1. MUTATION SURVIVOR -> REAL TEST GAP. Dropping `func != "count"` from the aggregate guard (leaving only `expr_col is not None`) survived the entire suite. The shape it admits -- avg/sum/min/max with NO expression alias -- is unreachable from the cypher surface, because the fast path refuses a non-count aggregate without an expression long before the fused lane is built. But the gate is a standalone function whose contract is checked here, not inherited, so the two cases now exist and the mutant dies. 2. MUTATION SURVIVOR -> PROVABLY EQUIVALENT REWRITE, disclosed as such. Relaxing `len(owners) != 1` to `len(owners) < 1` also survives, and no input can distinguish the two: two owners means a second alias with a NON-EMPTY property list, which the very next check declines anyway. The guard is kept because it states the precondition the height bound rests on; the test now says outright that it is redundant and why, rather than implying coverage that does not exist. 3. A FIXTURE DEFECT THAT ONLY THE GPU IMAGE COULD SEE. Two fixtures built `age` as an OBJECT column of ints and Nones via pd.concat. pandas and polars ingest that happily; cudf raises MixedTypeError. It produced 28 failures on dgx-spark inside the RAPIDS image and ZERO locally. Dtypes are now explicit, with a comment saying why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
Records what was measured, what the bounds are, why they are UPPER bounds, and the ceiling -- q4's last 20k loss narrows to near-parity, it does not become a win. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
…rs lane Caught by the repo's own polars-lane completeness meta-test, which is the only thing standing between a new polars test module and running in no lane at all. It was the ONLY new failure the full-suite A/B on dgx surfaced (comb vs comb+gate, RAPIDS image, --gpus all). 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
…a too #1818 landed the pyg-bench docs-numbers contract and its data file, which ships graphbench.{20k,100k}.q8.polars and .q8.polars_vs_kuzu as board_quotable=true with an empty disclosures list. That is the same claim this PR withdraws from the CHANGELOG, in a second location, so it is corrected the same way rather than left to be found later. The correction is expressed through the contract's own mechanism, not around it. Two invariants define the shape: quotable_implies_comparable board_quotable implies comparison_allowed caveat_travels_with_the_number status != 'ok' or comparison_allowed is false implies disclosures is non-empty So the four cells become comparison_allowed=false (a warm-memo figure is not a fair one-shot comparison), board_quotable=false (forced by the first invariant), and carry a disclosure recording the warm/cold/fresh arms, the bind_only control, and the one-shot loss factors. Re-checked all 119 cells against cell_required, both quotable invariants, caveat_travels_with_the_number and cell_names_a_known_run: zero violations. Diff is exactly those 4 cells; kuzu and pandas cells are untouched. This is an INTERIM correction on the consumer side. The durable fix is to regenerate the run in pyg-bench and re-copy the artifact (#1827) -- these figures are from graphbench-q1q9-*-20260726 and also predate #1828's q4 result, so the whole graphbench block wants a fresh run, not four hand-edits. 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
#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
Stacked on #1800 so the shared CHANGELOG stops conflicting on every landing. Files touched by both branches were 3-way merged with zero conflict markers; two (test_exec_context_scoping.py, test_varlen_bounded_engine_parity_1787.py) arrived on BOTH sides from master byte-identical and were taken as-is. Supersedes an earlier attempt whose tree was WRONG: the overlay step tested membership with a shell glob against a newline-separated list, so it never matched and re-added every already-merged file from the child ref, clobbering the merge. That silently reverted #1828's low-cardinality count gate out of gfql_fast_paths.py (_LOWCARD_COUNT_MAX_GROUPS: 4 occurrences -> 0) and dropped five CHANGELOG lines. Caught by checking the merged file for the parent's symbols instead of trusting rc=0. Membership is now an exact grep -qxF. CHANGELOG hand-resolved: a textual 3-way conflicted and the add-only fallback does not apply, because this branch REWRITES one of its own entries (the unbacked 288-cell figure). Built as the parent file plus this branch's two ### Fixed entries, each asserted to appear exactly once. 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 this is
Inside the fused single-hop grouped-aggregate lane (#1823, merged), a single-key pure
count(*)has a second, value-identical polars formulation:value_countsinstead ofgroup_by(maintain_order=True).agg(pl.len()). On the graph-benchmark q4 cell it is worth −1.46 ms, and q4 is the board's last 20k loss.It is not a drop-in, and that is the entire content of this PR. Applied ungated, the identical formulation makes the q1 cell +2.7 ms slower at 20k and +8.6 ms at 100k, because q1 groups by
personID(~20,000 groups over ~200,000 rows) — and q1 currently wins. Trading the last loss for a damaged win is not a trade, it is disqualifying. So the change is a gate, and the review question is whether the gate is sound.Base: current
master(7d0d0fc2e, which includes #1823). This PR was originally stacked; #1823 merged mid-measurement, so everything below was re-measured against real master and the PR is now independently mergeable. (#1800 and #1816 remain open — that is why q5/q7/q9 are losses on master today. Those are their cells, not this one's.)The ceiling, stated up front
Where it engages, this takes q4@20k from 1.39× to 0.97× of same-session Kuzu. Under the board's overlap rule that is a TIE, not a win. It removes the last 20k loss; it does not flip a cell to a win.
The thresholds were committed BEFORE validation
Commit
8eb6026b6fixes the two numbers and contains nothing else. At that point no verdict lane, no q1–q9 run, no differential and no mutation test had been run against the gate — a threshold chosen after seeing the verdicts is unfalsifiable.They come from two interleaved crossover sweeps on dgx-spark under the exclusive perf lock, in
graphistry/test-rapids-official:26.02-gfql-polars, polars 1.35.2, 20 threads, A,B,B,A / B,A,A,B per round, 15 rounds × 3 reps = 90 samples/arm/cell, 214 cells, ZERO value mismatches, count dtypeUInt32on both arms in every cell.The curve is two-dimensional; neither bound alone is sound.
group_bycarries a flat ~2 ms coordination cost present only at low cardinality, gone between 32 and 64 groups:group_by(...).agg(pl.len())value_countshas no such cost but scales worse with input rows — at 1,000,000 rows it loses even at 2 groups (4.137 → 8.591 ms).worstbelow is the conservative ratio group_by p25 ÷ value_counts p75.Cardinality → 32. Min
worstover both dtypes for all row counts ≤ 100,000: 2 groups 1.17, 3 → 1.24, 8 → 1.23, 16 → 1.27, 32 → 1.30; 48 → 0.64 FAILS; 64 → 0.50 FAILS.Rows → 100,000. Min
worstfor every cardinality ≤ 32 (string keys bind):Why the bounds are UPPER bounds, and what happens when they are loose
Both are static, O(1) in the data, and both over-estimate.
propout of that one frame, so every group value in the aggregate input is apropvalue of some row of it. Distinct values cannot exceed its height.heightis metadata.Looseness is safe by construction. An over-estimate can only make the gate decline a shape the fast formulation would have served. It can never route a high-cardinality or high-row aggregate into the slow-for-that-shape formulation — the failure that would matter. The price is real: q4 at 100k declines, because its City frame is 7,117 rows carrying only 3 distinct countries and an O(1) height bound cannot see the 3.
Strictly additive: every decline returns
Noneand the caller keeps the existinggroup_by, so the blast radius is a decline away from zero.Shapes admitted / declined
Admitted: single group key + single pure
count(*)(includingcount(<alias>), which lowers to a row count); the group key supplied by exactly one alias; that alias the only one carrying properties; its frame ≤ 32 rows with unique node ids; edge frame ≤ 100,000 rows.Declined (each pinned by a test): more than one group key; more than one aggregate;
avg/sum/min/max;count(<property>)— it counts non-null values, not rows; a group key with no owning alias, or with two; a second alias contributing property columns; owner frame too tall, missing its node-id column, or carrying duplicate node ids; edge frame over the row bound;out_alias == group_key(both formulations raiseDuplicateError, so declining keeps the twin's error); a non-polars owner frame. A group key literally namedcountis served, not declined — the lane passesvalue_counts(name=...)rather than renaming.Never reached: any shape the fused lane's own order-totality gate declines first.
Engagement on the real board data — exactly one cell
Counter probe on the actual 20k/100k datasets:
q1 declines on both bounds at both scales (20,000 / 100,000-row owner frame; 199,939 / 2,417,738-row edge frame). q3 declines on shape (
avg). The falsifier is defended twice over. This also makes 100k a built-in null control: zero engagement, so every 100k delta is a noise measurement.Verdict lane
dgx-spark, exclusive perf lock held for the whole measurement, RAPIDS image
26.02-gfql-polarswith--gpus all, canonical query textgb_queries.pymd56e7ae268a5a41742587fcb87854b6e27, 24 position-balanced slots per scale (12/arm, ABBA BAAB ×3), Kuzu 0.11.3 re-run in-session at 4 points, per-slot medians (never best-of), pandas in every slot as the reconciliation control. Quiet box: load 0.53 at lock acquisition, max 3.27 across all 48 slots.Provenance:
git_full7d0d0fc2e…(master) vs588530ff9…(PR);diff -rqbetween the two build trees is exactly one runtime product file.20k — the cell this PR is for
Board 20k: 2W/3T/4L → 2W/4T/3L — the last 20k loss is gone.
Two things I would rather state than let a reader infer:
No-regression — reported explicitly, not just q4
All four are arm-vs-arm ties with overlapping ranges at both scales, and the gate provably declines or never reaches every one of them. q2@20k's +1.086 ms is the largest excursion in the table and is disclosed rather than smoothed: the ranges overlap, and the gate is called and declines on q2a, so it cannot be causal.
100k: 6W/1T/2L → 6W/1T/2L, all nine cells arm-vs-arm TIE. With zero engagement there, this doubles as the noise floor: −0.72 to +0.95 ms.
pandas control: ties on all nine cells at both scales — a clean null control confirming this is polars-only.
Value identity: 864 measured cells across 48 slots, both arms, both engines, both scales — one canonical value per query, zero errors, and no disagreement with same-session Kuzu on any cell.
Differential, mutation, GPU
Differential vs the unmodified product: every comparison runs the query twice — once with the gate live, once with it forced to decline, which is the pre-change code path — compared row-order and column-order sensitively including dtypes, because the two formulations produce the count column independently and an equal-valued column at a different width would be a real divergence. 14 shapes × 12 graphs × 4 engines, plus a pandas oracle. Zero divergences.
The graph corpus deliberately includes null / all-null / empty group keys, empty edges, no-matching-nodes, string ids, self-loops and parallel edges, dangling endpoints, duplicate node rows on each arm separately, and frames at exactly the two thresholds.
Soundness is tested, not just argued:
test_admitted_shapes_respect_the_measured_boundsreaches into the lane's own work frame on every admission across the whole corpus and asserts the realized cardinality ≤ 32, realized rows ≤ 100,000, and rows ≤ edge-frame height. An under-estimating bound fails there.Mutation testing — on dgx in the RAPIDS image with
--gpus all, baseline 1403 passed / 28 skipped: 15 of 16 killed. Both survivors were investigated rather than reported as a rate:allows_any_aggregate(dropfunc != "count") — a real test gap. The shape it admits is unreachable from the Cypher surface, but the gate is a standalone function whose contract is checked here, not inherited. Two unit cases added; the mutant now dies (0a7e7d966).owner_uniqueness_not_required(len(owners) != 1→< 1) — a provably equivalent rewrite, and it still survives. Two owners implies a second alias with a non-empty property list, which the very next check declines anyway, so no input can distinguish the two forms. The guard is kept because it states the precondition the height bound rests on; the test now says outright that it is redundant and why.GPU receipt:
graphistry/test-rapids-official:26.02-gfql-polars,--gpus all, NVIDIA GB10, cudf 26.02.01 + cudf_polars. Gate suite: 1403 passed, 28 skipped. 169 cudf-parametrized and 168 polars-gpu-parametrized tests actually ran — not skipped. All 28 skips are the single disclosed pandas-oracle exclusion (the pre-existing polars-vs-pandas node-id dedup divergence from #1823).That GPU run earned its cost: two fixtures built
ageas an object column of ints and Nones, which cudf rejects withMixedTypeError— 28 failures on dgx, zero locally. Fixed with explicit dtypes.Full suite A/B on dgx, master vs master+gate, same image,
--gpus all:7d0d0fc2eZero new failures, zero removed — identical failure sets. (+1030 tests and +28 skips are this PR's own file.) The one new failure an earlier A/B did surface was the repo's polars-lane completeness meta-test, fixed in
588530ff9.bin/lint.shandbin/typecheck.shclean (mypy: no issues in 325 source files). NoAny,cast,getattr,setattr, barelist,Dict[str, Any]orobjectintroduced..github/workflows/untouched.Structural lock-in
pyg-bench graphistry/pyg-bench#117 pins which side of the gate each board cell lands on — q4 must take the fast formulation, q1/q2a/q3 must not — plus that growing the group-key frame past the bound flips q4 itself to the slow formulation (the gate reads data, not the query name). Never a growth-ratio or wall-clock gate: this is a constant-factor formulation swap, so a timing gate would keep passing after q4 stopped taking it and would not notice at all if q1 started.
Verified against both trees: 10/10 pass with the gate, 9 fail / 1 pass without it — it fails loudly when the lane is absent rather than skipping.
Honest limits
n_uniqueon the property) would engage it, but costs O(height) on every call — including q1, where the frame is 100,000 rows — so it was not taken.IMGescaped for remote expansion) meant every docker slot silently produced no output. Only the quiet, complete, master-based run above is reported.gfql_fast_paths.pyis byte-identical before and after the rebase (md5def6827882bf3133d45365ff92bea7ab), and the rebase carried no product change from perf(gfql): fuse the polars single-hop grouped aggregate into one lazy plan #1823's final head.🤖 Generated with Claude Code
https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx