Commit 7d0d0fc
perf(gfql): fuse the polars single-hop grouped aggregate into one lazy plan (#1823)
`_execute_single_hop_grouped_aggregate_fast_path` serves the cypher shape
MATCH (a {..})-[{..}]->(b {..}) [WHERE ..]
RETURN <alias>.<prop> AS k, <agg> AS v ORDER BY .. [LIMIT n]
which is three of the nine matched graph-benchmark cells (q1, q3, q4). Its
polars branch chained ~7 EAGER ops -- two semi-joins, two property joins, a
group_by, a sort and a head, each its own lazy().collect(_eager=True) -- so
every intermediate materialized, the select([src, dst]) projection could not
be pushed into the semi-joins, and the head() could not reach into the plan
at all. 74-96% of each of those queries' wall time sat inside those collects.
The same op sequence is now built as ONE lazy plan collected once. The algebra
is character-identical to the eager twin (same .unique() id frames, same
semi-joins, same UN-deduplicated property lookups, same
group_by(maintain_order=True).agg(..), same per-key nulls_last sort, same
head), so the value is identical, row ORDER included.
STRICTLY ADDITIVE: the eager code is untouched and is the fallback on every
decline, so the blast radius is a decline away from zero.
DECLINES (never a different answer, only a forgone speedup):
* a non-eager-polars input frame;
* a property column missing from its alias' node frame -- the eager twin
discovers this MID-CHAIN and declines the WHOLE fast path, so the guard
is HOISTED ahead of plan construction; otherwise the fused lane would
answer a query that is supposed to raise;
* source and destination bound to the same edge column;
* a projected column colliding with an endpoint column or the lookup key;
* an untranslatable aggregate;
* a result row order that ORDER BY does not fully determine.
That last one is the correctness crux and it is MEASURED, not assumed:
without every group key in the sort, the twin's order falls back to
maintain_order=True group first-appearance order over an EAGER join output,
which a lazy plan is free to change. An ungated variant of the same plan
diverged from the twin in 47 of 64 comparisons (4 graph sizes x 4 seeds x 4
order-undetermined shapes), and under LIMIT the divergence is a different ROW
SET, not merely a different row order.
MEASURED, dgx-spark, matched q1-q9 lane, canonical query text (gb_queries.py,
md5 6e7ae268a5a41742587fcb87854b6e27 @ pyg-bench 7e5ce3b), one perf lock per
experiment, master tree vs PR tree position-balanced M P P M P M M P, per-slot
medians, replicated in a second independent run:
20k q1 13.31 -> 8.96 (-32.7%) q3 8.54 -> 5.53 (-35.3%)
q4 6.99 -> 5.05 (-27.8%) all non-overlapping in both runs
100k q1 42.59 -> 31.45 (-26.2%) q4 12.19 -> 10.43 (-14.4%)
q3 -9.1%..-13.0%
vs SAME-SESSION embedded Kuzu at 20k: q1 widens 1.12x -> 1.66x; q3 moves from
a 1.37x LOSS to a TIE (slot ranges overlap -- a tie, NOT a win); q4 narrows
from 2.10x to 1.51x and REMAINS A LOSS. q5 and q8 are ties at both scales in
both runs and q8 stays a win -- this lane is not called for them.
Value identity: one canonical row set per query across every slot of every
run. Differential over 19 shapes x 10 graphs, compared row-order AND
column-order sensitively against both the eager code and the pandas oracle:
zero divergences from the eager code.
DISCLOSED, pre-existing, deliberately NOT changed here: the polars property
lookup is not deduplicated by node id while the pandas one is, so a node table
carrying the same id twice multiplies matched rows on polars only. The fused
lane reproduces the eager polars answer exactly and a test pins both sides.
Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>1 parent 7bc9b00 commit 7d0d0fc
4 files changed
Lines changed: 1003 additions & 1 deletion
File tree
- bin
- graphistry
- compute
- tests/compute/gfql/cypher
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| 17 | + | |
17 | 18 | | |
18 | 19 | | |
19 | 20 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
| 40 | + | |
40 | 41 | | |
41 | 42 | | |
42 | 43 | | |
| |||
0 commit comments