Skip to content

perf(gfql): gate a low-cardinality pure count(*) onto value_counts, and keep q1 off it - #1828

Merged
lmeyerov merged 4 commits into
masterfrom
perf/gfql-lowcard-count-gate
Jul 28, 2026
Merged

perf(gfql): gate a low-cardinality pure count(*) onto value_counts, and keep q1 off it#1828
lmeyerov merged 4 commits into
masterfrom
perf/gfql-lowcard-count-gate

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

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_counts instead of group_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 8eb6026b6 fixes 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 dtype UInt32 on both arms in every cell.

The curve is two-dimensional; neither bound alone is sound. group_by carries a flat ~2 ms coordination cost present only at low cardinality, gone between 32 and 64 groups:

int keys, 20,000 rows 32 groups 48 groups 64 groups
group_by(...).agg(pl.len()) 2.054 ms 0.411 ms 0.291 ms

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 → 8.591 ms).

worst below is the conservative ratio group_by p25 ÷ value_counts p75.

Cardinality → 32. Min worst over 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 worst for every cardinality ≤ 32 (string keys bind):

rows str int
20,000 3.08 4.63 safe
35,585 2.26 3.29 safe
50,000 1.77 2.78 safe
75,000 1.36 2.15 safe
100,000 1.17 1.90 last safe
150,000 0.80 1.48 fails
200,000 0.69 1.22 fails
400,000 0.66 0.78 fails

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.

  1. Group cardinality ≤ the HEIGHT of the alias node frame supplying the group key. That column is produced by an inner join reading prop out of that one frame, so every group value in the aggregate input is a prop value of some row of it. Distinct values cannot exceed its height. height is metadata.
  2. Aggregate input rows ≤ the height of the already-filtered EDGE frame. The two semi-joins only remove edge rows. The property inner-join can multiply them when a node id repeats — the lane deliberately does not dedup, because the eager twin does not. So the row bound is only claimed once the sole property join is known to be non-multiplying, which is why the gate additionally requires that exactly one alias carries properties and that its node ids are unique. That uniqueness check runs on a frame already known to be ≤ 32 rows, so it is bounded work regardless of graph size.

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 None and the caller keeps the existing group_by, so the blast radius is a decline away from zero.

Shapes admitted / declined

Admitted: single group key + single pure count(*) (including count(<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 raise DuplicateError, so declining keeps the twin's error); a non-polars owner frame. A group key literally named count is served, not declined — the lane passes value_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 q2a q3 q4 q5–q9
20k declined declined declined ADMITTED never reached
100k declined declined declined declined never reached

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-polars with --gpus all, canonical query text gb_queries.py md5 6e7ae268a5a41742587fcb87854b6e27, 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_full 7d0d0fc2e… (master) vs 588530ff9… (PR); diff -rq between the two build trees is exactly one runtime product file.

20k — the cell this PR is for

Kuzu (4 slots) master (12) master+gate (12) vs Kuzu arm-vs-arm
q4 3.46 [2.77–3.77] 4.83 [4.06–5.47] LOSE 3.37 [2.85–4.03] TIE 1.39× → 0.97× WIN, −1.462 ms (−30.3%), ranges do not overlap
master per-slot: 4.996 4.878 4.323 4.671 5.193 5.016 4.371 5.425 4.778 4.063 5.475 4.354
gate   per-slot: 3.751 2.977 3.972 3.253 3.336 4.035 3.030 3.618 3.395 2.851 3.497 3.004
kuzu   per-slot: 3.766 2.766 3.528 3.400

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

master master+gate Δ ms arm-vs-arm vs Kuzu
q1 @20k 8.79 [8.40–9.84] 8.61 [7.90–9.09] −0.184 TIE WIN → WIN
q2 @20k 11.76 [11.45–12.95] 12.85 [11.75–13.85] +1.086 TIE WIN → WIN
q3 @20k 5.70 [4.73–6.30] 5.64 [5.09–6.27] −0.059 TIE TIE → TIE
q8 @20k 1.93 [1.29–2.77] 2.04 [1.18–2.48] +0.106 TIE TIE → TIE
q1 @100k 31.27 30.82 −0.449 TIE WIN → WIN
q2 @100k 43.49 43.21 −0.284 TIE WIN → WIN
q3 @100k 13.42 12.89 −0.523 TIE WIN → WIN
q8 @100k 6.03 5.31 −0.718 TIE WIN → WIN

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_bounds reaches 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 (drop func != "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 age as an object column of ints and Nones, which cudf rejects with MixedTypeError — 28 failures on dgx, zero locally. Fixed with explicit dtypes.

Full suite A/B on dgx, master vs master+gate, same image, --gpus all:

failed passed skipped
master 7d0d0fc2e 17 8705 235
master + gate 17 9735 263

Zero 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.sh and bin/typecheck.sh clean (mypy: no issues in 325 source files). No Any, cast, getattr, setattr, bare list, Dict[str, Any] or object introduced. .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

  • q4@20k is a TIE, not a win. The median edges Kuzu (3.37 vs 3.46) but the ranges overlap, which is exactly what the board's rule calls a tie.
  • q4@100k does not engage. The O(1) bound cannot see that 7,117 City rows carry 3 countries. A tighter bound (an actual n_unique on 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.
  • Two runs were discarded and are not reported, rather than being quietly averaged in: one where the box was contended (load 5–11; its 100k null control showed ±12 ms of noise), and one where a harness defect (IMG escaped for remote expansion) meant every docker slot silently produced no output. Only the quiet, complete, master-based run above is reported.
  • The mutation and GPU runs were executed on the pre-rebase tree; gfql_fast_paths.py is byte-identical before and after the rebase (md5 def6827882bf3133d45365ff92bea7ab), 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

lmeyerov and others added 4 commits July 28, 2026 01:34
…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
lmeyerov merged commit caf57d5 into master Jul 28, 2026
77 checks passed
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
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