perf(gfql): build grouped-aggregate outputs in one pass to keep pandas blocks consolidated - #1922
Conversation
…s consolidated #1901 made `a.id AS id` survive as an output by moving the join-key rename() BEFORE the per-output column assignments. That fixed the KeyError but cost pandas throughput: rename() returns a CONSOLIDATED copy, so in the old order it ran last and the lookup entered the ~200k-row inner merge as one block. Running it first leaves the later column-by-column writes to fragment the frame, and the merge then pays per-block take plus _merge_blocks/vstack. Whole-board A/B saw pandas q1 +11.3% @20k / +6.9% @100k and q2 +10.0% / +6.7% (q2 runs q1's shape as its first leg); polars flat; every cell byte-identical, i.e. pure overhead. Fix: build the key rename and every projected output in ONE frame construction (dict of columns -> df_cons(engine)(...)), which consolidates by dtype naturally. No private pandas consolidation API. Correctness from #1901 is preserved -- the key is still renamed away from node_col, so `a.id AS id` survives as an output and an output named like the node-id column cannot corrupt the join key (pinned in test_count_and_param_semantics.py). Same treatment for the sibling two-star grouped-count lookup, which had the identical rename()-then-per-column-write shape on the same hot merge. Local microbenchmark (NOT the board; a dgx board A/B confirms separately). 200k-edge q1 shape, 10 interleaved process rounds x 9 reps each, this branch tip (BASE) vs this commit (FIX), medians of round medians; `lookup` block state spied in situ on pd.DataFrame.merge: grouped-aggregate lookup, int group key (`c.bucket`) blocks BASE 2, not consolidated -> FIX 1, consolidated merge 11.41 ms -> 6.78 ms (-40.6%, n=120 each) query 36.72 ms -> 30.01 ms (-18.3%, FIX faster in 10/10 rounds) grouped-aggregate lookup, int + string group keys blocks BASE 3, not consolidated -> FIX 2, consolidated merge 14.63 ms -> 10.16 ms (-30.6%) query 52.58 ms -> 45.49 ms (-13.5%, 10/10 rounds) grouped-aggregate lookup, string group key only blocks BASE 2, consolidated -> FIX 2, consolidated (BASE already lands consolidated here -- a single object column self-assigned) query 43.75 ms -> 43.63 ms (-0.3%, no change, as expected) two-star grouped count, two string group keys (120k-edge shape) blocks BASE 3, not consolidated -> FIX 2, consolidated merge 11.70 ms -> 8.41 ms (-28.1%, n=100 each) query 82.42 ms -> 80.76 ms (-2.0%; the merge is a small slice of this query, so the total-level win is weak -- 7/10 rounds) Values identical before and after on every fixture measured (sha256 of the canonicalized result frame matches for all four shapes). Gates on this GPU-less box: graphistry/tests/compute/gfql -> 96 failed, 8809 passed, 707 skipped, 32 xfailed on BOTH the branch tip and this commit, with a byte-identical FAILED set (md5 19e4fee752c39848ff2c6db122249d14). gfql/cypher -k "not cudf" 3306 passed / 0 failed. test_compute_chain.py + test_compute_hops.py 138 passed, 2 failed (both cudf-only). ruff, mypy (329 files), type-hygiene and cypher-surface guards all clean. The grouped-aggregate fast path still ENGAGES (test_fast_path_engagement.py green). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
|
Board A/B: 18 slots per build per scale (108 slots, 1944 cell measurements), frozen builds sha-verified byte-exact against their git blobs, Verdict: recovers, but I will not claim provably 100%.
FIX vs BASE is statistically indistinguishable from zero in all four pandas cells, and FIX vs TIP is a large Bonferroni-significant improvement. Honest caveat: every FIX-vs-BASE point estimate sits positive (+2.7% to +4.1%) and the bootstrap recovery fraction is 65% [56,112] / 65% [54,109] at 100k, so a residual up to ~+3.5% at 100k cannot be excluded. What the data firmly excludes is anything near the original 8%/15%. The defensible statement is "recovered to within noise, with a possible ~3% residual at 100k" — not "fully recovered". The regression reproduced somewhat larger than first measured (+8.1/+7.6 @100k vs +6.9/+6.7; +15.4/+14.5 @20k vs +11.3/+10.0), consistent with the known cross-session drift of intermediate heads. q5–q9 @100k, FIX vs TIP: all ten cells ns. The two-star hunk did not perturb q5/q6/q7 — which are exactly its shape — and also produced no measurable win there, consistent with its weak local result. The one cell worth a re-check is pandas q6 @20k (FIX vs TIP +1.3%, uncorrected-sig only, ns under Bonferroni; +0.8% ns at 100k). Separate finding, not this PR's: polars q8 shows TIP vs BASE +7.8% SIG @100k with FIX vs TIP ns — a stack-level residual this change neither causes nor fixes. That is the known trail-correctness cost (self-loop exclusion from |
…onsolidation branch
CI was red on three lanes -- test-core-python (3.11),
test-pandas-compat-gfql (legacy, py3.9) and test-gfql-core (3.14) -- with the
same two failures in every lane:
FAILED graphistry/tests/compute/test_hop.py::TestMultiHopForward::
test_hop_fixedpoint_undirected_does_not_revisit_seed_via_same_edge
AssertionError: assert {'a', 'b', 'c', 'd', 'e'} == {'b', 'c', 'd', 'e'}
FAILED graphistry/tests/compute/test_hop.py::TestMultiHopForward::
test_hop_fixedpoint_undirected_excludes_unrediscovered_seeds_in_disconnected_components
AssertionError: assert {'a', 'b', 'x', 'y'} == {'b', 'y'}
One root cause, not three, and not the version skew the three-Python spread
suggested: the failure is identical on py3.9/legacy-pandas, py3.11 and py3.14,
and it reproduces on py3.12 locally.
This branch was cut from 98c0441, an intermediate #1918 state in which the
undirected fixed-point wavefront re-admitted the seed. The base branch has since
advanced to 0ddf565, which reconciles the wavefront onto to_fixed_point and
restores the seed-exclusion semantics those two pins assert. The branch never
picked that up, so it carried the pre-fix hop.py under post-fix pins.
The pins are correct as written and are left untouched: hopping undirected to a
fixed point from seed {a} over the chain a-b-c-d-e must return the wave front
{b, c, d, e}; `a` is only re-admitted if some edge rediscovers it, and the sole
edge incident to `a` is the one already traversed to leave it.
Merge is clean. The branch's own content delta against base is unchanged --
still exactly the 19/10 line block-consolidation edit in gfql_fast_paths.py.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Cascading base update after #1920 took its base and, critically, finally picked up the 23 commits it was behind. ZERO conflicted files -- the earlier abort on this branch was correct: the hop.py conflict belonged in #1920, and once resolved there this merge is mechanical. This is the merge that should clear the test-gfql-core (3.12) coverage-floor failure: the temporal test files the per-file floors need are now on the head (tests/compute/gfql/cypher/test_temporal_arithmetic_folding_branches.py, tests/compute/gfql/cypher/test_native_temporal_resolution.py) along with tests/compute/gfql/coverage_baselines/ci-pandas-py3.12.json. Gates: no conflict markers, ruff clean, type-hygiene guard clean, cypher surface guard pass, mypy shows only the 4 known polars-skew errors. The two undirected-fixed-point regression detectors in test_hop.py pass. Fast-path + hop-semantics + boundary-matrix + temporal + trail suites = 1048 passed, 1 failed ([cudf], no GPU here). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Recovers the one real regression the whole-stack board A/B found: pandas q1 +11.3% @20k / +6.9% @100k, q2 +10.0% / +6.7% (q2 runs q1's shape as its first leg). polars was flat and every board cell returned byte-identical values, so it was pure overhead.
Bisected to a single 4-line hunk in #1901's first commit. Making
a.id AS idsurvive as an output required moving the keyrename()before the per-output column assignments — butrename()returns a consolidated copy, so running it first leaves the later per-column writes creating unconsolidated blocks, and the ~200k-row inner merge then pays per-block take plus_merge_blocks/vstack. Instrumented on the real q1: parent 1 block / 5.58 ms, culprit 2 blocks / 7.63 ms, against a +2.6 ms board delta; cProfile agreed with identical call counts and a byte-identical compiled plan.Fix: build the projected frame in ONE construction (a dict of Series handed to
df_cons) instead of assigning column-by-column after the rename — which is also this repo's standing cheap-write guidance. No private pandas API. The.copy()became unnecessary and is gone. The correctness #1901 added is intact: the key is still renamed away fromnode_col, soa.id AS idsurvives and an output named like the node-id column cannot corrupt the join key.Local microbenchmark (10 interleaved rounds × 9 reps, medians of round medians — not the board; a dgx A/B confirms separately):
The string-only row is the honest control: that shape is already consolidated at base, so there is nothing to win — the mechanism confirming itself rather than a uniform speedup. Result-frame sha256 identical base vs fix on all four shapes.
Sibling sweep: all 113
rename(columns=…)sites ingraphistry/compute/checked for a following column-write; exactly two had the pathology (the culprit and the two-star grouped-count twin), both fixed. The polars twins already build in oneselect/with_columns, consistent with polars measuring flat on the board.Gates: full-tree failure set byte-identical (md5 matched), fast path still engages, typecheck 329 clean, hygiene guard no growth — the first draft needed a
cast()that tripped the explicit-cast guard, fixed by annotating the dict properly rather than raising the cap.Not claimed: the two-star sibling's query-level −2.0% is weak and shouldn't be quoted (its merge-level −28% and block-state change are unambiguous); no cuDF verification on a GPU-less box; board confirmation still outstanding.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi